diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/account/wizard | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/account/wizard')
28 files changed, 2520 insertions, 0 deletions
diff --git a/addons/account/wizard/__init__.py b/addons/account/wizard/__init__.py new file mode 100644 index 00000000..313b31cc --- /dev/null +++ b/addons/account/wizard/__init__.py @@ -0,0 +1,19 @@ +# -*- 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_validate_account_move +from . import pos_box +from . import account_move_reversal +from . import account_report_common +from . import account_report_common_journal +from . import account_report_print_journal +from . import account_resequence +from . import setup_wizards +from . import wizard_tax_adjustments +from . import account_invoice_send +from . import base_document_layout +from . import account_payment_register +from . import account_tour_upload_bill diff --git a/addons/account/wizard/account_automatic_entry_wizard.py b/addons/account/wizard/account_automatic_entry_wizard.py new file mode 100644 index 00000000..b0089c07 --- /dev/null +++ b/addons/account/wizard/account_automatic_entry_wizard.py @@ -0,0 +1,431 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from odoo.tools.misc import format_date, formatLang + +from collections import defaultdict +from itertools import groupby +import json + +class AutomaticEntryWizard(models.TransientModel): + _name = 'account.automatic.entry.wizard' + _description = 'Create Automatic Entries' + + # General + action = fields.Selection([('change_period', 'Change Period'), ('change_account', 'Change Account')], required=True) + move_data = fields.Text(compute="_compute_move_data", help="JSON value of the moves to be created") + preview_move_data = fields.Text(compute="_compute_preview_move_data", help="JSON value of the data to be displayed in the previewer") + move_line_ids = fields.Many2many('account.move.line') + date = fields.Date(required=True, default=lambda self: fields.Date.context_today(self)) + company_id = fields.Many2one('res.company', required=True, readonly=True) + company_currency_id = fields.Many2one('res.currency', related='company_id.currency_id') + 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')]", + compute="_compute_journal_id", + inverse="_inverse_journal_id", + help="Journal where to create the entry.") + + # 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)," + "('internal_type', 'not in', ('receivable', 'payable'))," + "('is_off_balance', '=', False)]", + compute="_compute_expense_accrual_account", + inverse="_inverse_expense_accrual_account", + ) + revenue_accrual_account = fields.Many2one('account.account', readonly=False, + domain="[('company_id', '=', company_id)," + "('internal_type', 'not in', ('receivable', 'payable'))," + "('is_off_balance', '=', False)]", + compute="_compute_revenue_accrual_account", + inverse="_inverse_revenue_accrual_account", + ) + + # change account + destination_account_id = fields.Many2one(string="To", comodel_name='account.account', help="Account to transfer to.") + display_currency_helper = fields.Boolean(string="Currency Conversion Helper", compute='_compute_display_currency_helper', + help="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.") + + @api.depends('company_id') + def _compute_expense_accrual_account(self): + for record in self: + record.expense_accrual_account = record.company_id.expense_accrual_account_id + + def _inverse_expense_accrual_account(self): + for record in self: + record.company_id.sudo().expense_accrual_account_id = record.expense_accrual_account + + @api.depends('company_id') + def _compute_revenue_accrual_account(self): + for record in self: + record.revenue_accrual_account = record.company_id.revenue_accrual_account_id + + def _inverse_revenue_accrual_account(self): + for record in self: + record.company_id.sudo().revenue_accrual_account_id = record.revenue_accrual_account + + @api.depends('company_id') + def _compute_journal_id(self): + for record in self: + record.journal_id = record.company_id.automatic_entry_default_journal_id + + def _inverse_journal_id(self): + for record in self: + record.company_id.sudo().automatic_entry_default_journal_id = record.journal_id + + @api.constrains('percentage', 'action') + def _constraint_percentage(self): + for record in self: + if not (0.0 < record.percentage <= 100.0) and record.action == 'change_period': + raise UserError(_("Percentage must be between 0 and 100")) + + @api.depends('percentage', 'move_line_ids') + def _compute_total_amount(self): + for record in self: + record.total_amount = (record.percentage or 100) * sum(record.move_line_ids.mapped('balance')) / 100 + + @api.depends('total_amount', 'move_line_ids') + def _compute_percentage(self): + for record in self: + total = (sum(record.move_line_ids.mapped('balance')) or record.total_amount) + if total != 0: + record.percentage = (record.total_amount / total) * 100 + else: + record.percentage = 100 + + @api.depends('move_line_ids') + def _compute_account_type(self): + for record in self: + record.account_type = 'income' if sum(record.move_line_ids.mapped('balance')) < 0 else 'expense' + + @api.depends('destination_account_id') + def _compute_display_currency_helper(self): + for record in self: + record.display_currency_helper = bool(record.destination_account_id.currency_id) + + @api.model + def default_get(self, fields): + res = super().default_get(fields) + if not set(fields) & set(['move_line_ids', 'company_id']): + return res + + if self.env.context.get('active_model') != 'account.move.line' or not self.env.context.get('active_ids'): + raise UserError(_('This can only be used on journal items')) + move_line_ids = self.env['account.move.line'].browse(self.env.context['active_ids']) + 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.')) + 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(_('You cannot use this wizard on journal entries belonging to different companies.')) + res['company_id'] = move_line_ids[0].company_id.id + + allowed_actions = set(dict(self._fields['action'].selection)) + if self.env.context.get('default_action'): + allowed_actions = {self.env.context['default_action']} + if any(line.account_id.user_type_id != move_line_ids[0].account_id.user_type_id for line in move_line_ids): + allowed_actions.discard('change_period') + if not allowed_actions: + raise UserError(_('No possible action found with the selected lines.')) + res['action'] = allowed_actions.pop() + return res + + def _get_move_dict_vals_change_account(self): + line_vals = [] + + # Group data from selected move lines + counterpart_balances = defaultdict(lambda: defaultdict(lambda: 0)) + grouped_source_lines = defaultdict(lambda: self.env['account.move.line']) + + for line in self.move_line_ids.filtered(lambda x: x.account_id != self.destination_account_id): + counterpart_currency = line.currency_id + counterpart_amount_currency = line.amount_currency + + if self.destination_account_id.currency_id and self.destination_account_id.currency_id != self.company_id.currency_id: + counterpart_currency = self.destination_account_id.currency_id + counterpart_amount_currency = self.company_id.currency_id._convert(line.balance, self.destination_account_id.currency_id, self.company_id, line.date) + + counterpart_balances[(line.partner_id, counterpart_currency)]['amount_currency'] += counterpart_amount_currency + counterpart_balances[(line.partner_id, counterpart_currency)]['balance'] += line.balance + grouped_source_lines[(line.partner_id, line.currency_id, line.account_id)] += line + + # Generate counterpart lines' vals + for (counterpart_partner, counterpart_currency), counterpart_vals in counterpart_balances.items(): + source_accounts = self.move_line_ids.mapped('account_id') + counterpart_label = len(source_accounts) == 1 and _("Transfer from %s", source_accounts.display_name) or _("Transfer counterpart") + + if not counterpart_currency.is_zero(counterpart_vals['amount_currency']): + line_vals.append({ + 'name': counterpart_label, + 'debit': counterpart_vals['balance'] > 0 and self.company_id.currency_id.round(counterpart_vals['balance']) or 0, + 'credit': counterpart_vals['balance'] < 0 and self.company_id.currency_id.round(-counterpart_vals['balance']) or 0, + 'account_id': self.destination_account_id.id, + 'partner_id': counterpart_partner.id or None, + 'amount_currency': counterpart_currency.round((counterpart_vals['balance'] < 0 and -1 or 1) * abs(counterpart_vals['amount_currency'])) or 0, + 'currency_id': counterpart_currency.id, + }) + + # Generate change_account lines' vals + for (partner, currency, account), lines in grouped_source_lines.items(): + account_balance = sum(line.balance for line in lines) + if not self.company_id.currency_id.is_zero(account_balance): + account_amount_currency = currency.round(sum(line.amount_currency for line in lines)) + line_vals.append({ + 'name': _('Transfer to %s', self.destination_account_id.display_name or _('[Not set]')), + 'debit': account_balance < 0 and self.company_id.currency_id.round(-account_balance) or 0, + 'credit': account_balance > 0 and self.company_id.currency_id.round(account_balance) or 0, + 'account_id': account.id, + 'partner_id': partner.id or None, + 'currency_id': currency.id, + 'amount_currency': (account_balance > 0 and -1 or 1) * abs(account_amount_currency), + }) + + return [{ + 'currency_id': self.journal_id.currency_id.id or self.journal_id.company_id.currency_id.id, + 'move_type': 'entry', + 'journal_id': self.journal_id.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], + }] + + def _get_move_dict_vals_change_period(self): + # set the change_period account on the selected journal items + accrual_account = self.revenue_accrual_account if self.account_type == 'income' else self.expense_accrual_account + + 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': _('Adjusting Entry'), + 'date': fields.Date.to_string(self.date), + 'journal_id': self.journal_id.id, + }} + # complete the account.move data + for date, grouped_lines in groupby(self.move_line_ids, lambda m: m.move_id.date): + grouped_lines = list(grouped_lines) + amount = sum(l.balance for l in grouped_lines) + move_data[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(_('Adjusting Entry of {date} ({percent:f}% recognized on {new_date})'), grouped_lines[0].move_id, amount), + 'date': fields.Date.to_string(date), + 'journal_id': self.journal_id.id, + } + + # compute the account.move.lines and the total amount per move + for aml in self.move_line_ids: + # account.move.line data + 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) + + move_data['new_date']['line_ids'] += [ + (0, 0, { + 'name': aml.name or '', + 'debit': reported_debit, + 'credit': reported_credit, + 'amount_currency': reported_amount_currency, + 'currency_id': aml.currency_id.id, + 'account_id': aml.account_id.id, + 'partner_id': aml.partner_id.id, + }), + (0, 0, { + 'name': _('Adjusting Entry'), + 'debit': reported_credit, + 'credit': reported_debit, + 'amount_currency': -reported_amount_currency, + 'currency_id': aml.currency_id.id, + 'account_id': accrual_account.id, + 'partner_id': aml.partner_id.id, + }), + ] + move_data[aml.move_id.date]['line_ids'] += [ + (0, 0, { + 'name': aml.name or '', + 'debit': reported_credit, + 'credit': reported_debit, + 'amount_currency': -reported_amount_currency, + 'currency_id': aml.currency_id.id, + 'account_id': aml.account_id.id, + 'partner_id': aml.partner_id.id, + }), + (0, 0, { + 'name': _('Adjusting Entry'), + 'debit': reported_debit, + 'credit': reported_credit, + 'amount_currency': reported_amount_currency, + 'currency_id': aml.currency_id.id, + 'account_id': accrual_account.id, + 'partner_id': aml.partner_id.id, + }), + ] + + move_vals = [m for m in move_data.values()] + return move_vals + + @api.depends('move_line_ids', 'journal_id', 'revenue_accrual_account', 'expense_accrual_account', 'percentage', 'date', 'account_type', 'action', 'destination_account_id') + def _compute_move_data(self): + for record in self: + if record.action == 'change_period': + if any(line.account_id.user_type_id != record.move_line_ids[0].account_id.user_type_id for line in record.move_line_ids): + raise UserError(_('All accounts on the lines must be of the same type.')) + if record.action == 'change_period': + record.move_data = json.dumps(record._get_move_dict_vals_change_period()) + elif record.action == 'change_account': + record.move_data = json.dumps(record._get_move_dict_vals_change_account()) + + @api.depends('move_data') + def _compute_preview_move_data(self): + for record in self: + preview_columns = [ + {'field': 'account_id', 'label': _('Account')}, + {'field': 'name', 'label': _('Label')}, + {'field': 'debit', 'label': _('Debit'), 'class': 'text-right text-nowrap'}, + {'field': 'credit', 'label': _('Credit'), 'class': 'text-right text-nowrap'}, + ] + if record.action == 'change_account': + preview_columns[2:2] = [{'field': 'partner_id', 'label': _('Partner')}] + + move_vals = json.loads(record.move_data) + preview_vals = [] + for move in move_vals[:4]: + preview_vals += [self.env['account.move']._move_dict_to_preview_vals(move, record.company_id.currency_id)] + preview_discarded = max(0, len(move_vals) - len(preview_vals)) + + record.preview_move_data = json.dumps({ + 'groups_vals': preview_vals, + 'options': { + 'discarded_number': _("%d moves", preview_discarded) if preview_discarded else False, + 'columns': preview_columns, + }, + }) + + def do_action(self): + move_vals = json.loads(self.move_data) + if self.action == 'change_period': + return self._do_action_change_period(move_vals) + elif self.action == 'change_account': + return self._do_action_change_account(move_vals) + + def _do_action_change_period(self, move_vals): + accrual_account = self.revenue_accrual_account if self.account_type == 'income' else self.expense_accrual_account + + created_moves = self.env['account.move'].create(move_vals) + created_moves._post() + + destination_move = created_moves[0] + destination_messages = [] + for move in self.move_line_ids.move_id: + amount = sum((self.move_line_ids._origin & move.line_ids).mapped('balance')) + accrual_move = created_moves[1:].filtered(lambda m: m.date == move.date) + + if accrual_account.reconcile: + to_reconcile = (accrual_move + destination_move).mapped('line_ids').filtered(lambda line: line.account_id == accrual_account) + to_reconcile.reconcile() + move.message_post(body=self._format_strings(_('Adjusting Entries have been created for this invoice:<ul><li>%(link1)s cancelling ' + '{percent:f}%% 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:f}% of {amount} recognized from {date}'), move, amount)] + accrual_move.message_post(body=self._format_strings(_('Adjusting Entry for {link}: {percent:f}% of {amount} recognized on {new_date}'), move, amount)) + destination_move.message_post(body='<br/>\n'.join(destination_messages)) + + # open the generated entries + action = { + 'name': _('Generated Entries'), + 'domain': [('id', 'in', created_moves.ids)], + 'res_model': 'account.move', + 'view_mode': 'tree,form', + 'type': 'ir.actions.act_window', + 'views': [(self.env.ref('account.view_move_tree').id, 'tree'), (False, 'form')], + } + if len(created_moves) == 1: + action.update({'view_mode': 'form', 'res_id': created_moves.id}) + return action + + def _do_action_change_account(self, move_vals): + new_move = self.env['account.move'].create(move_vals) + new_move._post() + + # Group lines + grouped_lines = defaultdict(lambda: self.env['account.move.line']) + destination_lines = self.move_line_ids.filtered(lambda x: x.account_id == self.destination_account_id) + for line in self.move_line_ids - destination_lines: + grouped_lines[(line.partner_id, line.currency_id, line.account_id)] += line + + # Reconcile + for (partner, currency, account), lines in grouped_lines.items(): + if account.reconcile: + to_reconcile = lines + new_move.line_ids.filtered(lambda x: x.account_id == account and x.partner_id == partner and x.currency_id == currency) + to_reconcile.reconcile() + + if destination_lines and self.destination_account_id.reconcile: + to_reconcile = destination_lines + new_move.line_ids.filtered(lambda x: x.account_id == self.destination_account_id and x.partner_id == partner and x.currency_id == currency) + to_reconcile.reconcile() + + # Log the operation on source moves + acc_transfer_per_move = defaultdict(lambda: defaultdict(lambda: 0)) # dict(move, dict(account, balance)) + for line in self.move_line_ids: + acc_transfer_per_move[line.move_id][line.account_id] += line.balance + + for move, balances_per_account in acc_transfer_per_move.items(): + message_to_log = self._format_transfer_source_log(balances_per_account, new_move) + if message_to_log: + move.message_post(body=message_to_log) + + # Log on target move as well + new_move.message_post(body=self._format_new_transfer_move_log(acc_transfer_per_move)) + + return { + 'name': _("Transfer"), + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'account.move', + 'res_id': new_move.id, + } + + # 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, move, balance) % {'account_source_name': account.display_name} + + rslt += '</ul>' + 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, transfer_move, balance) % account.display_name + return content and '<ul>' + content + '</ul>' or None + + def _format_move_link(self, move): + move_link_format = "<a href=# data-oe-model=account.move data-oe-id={move_id}>{move_name}</a>" + return move_link_format.format(move_id=move.id, move_name=move.name) + + def _format_strings(self, string, move, amount): + return string.format( + percent=self.percentage, + name=move.name, + id=move.id, + amount=formatLang(self.env, abs(amount), currency_obj=self.company_id.currency_id), + debit_credit=amount < 0 and _('C') or _('D'), + 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_target_name=self.destination_account_id.display_name, + ) diff --git a/addons/account/wizard/account_automatic_entry_wizard_views.xml b/addons/account/wizard/account_automatic_entry_wizard_views.xml new file mode 100644 index 00000000..70426742 --- /dev/null +++ b/addons/account/wizard/account_automatic_entry_wizard_views.xml @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + <record id="account_automatic_entry_wizard_form" model="ir.ui.view"> + <field name="name">account.automatic.entry.wizard.form</field> + <field name="model">account.automatic.entry.wizard</field> + <field name="arch" type="xml"> + <form> + <field name="account_type" invisible="1"/> + <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"> + 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> + <field name="action" invisible="context.get('hide_automatic_options')" widget="radio" options="{'horizontal': true}"/> + <group> + <group attrs="{'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')]}"/> + <field name="revenue_accrual_account" string="Accrued Account" + attrs="{'invisible': [('account_type', '!=', 'income')], 'required': [('account_type', '=', 'income'), ('action', '=', 'change_period')]}"/> + </group> + <group attrs="{'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)]"/> + </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" class="oe_inline" attrs="{'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="journal_id"/> + </group> + </group> + <label for="preview_move_data" string="The following Journal Entries will be generated"/> + <field name="preview_move_data" widget="grouped_view_widget"/> + <footer> + <button string="Create Journal Entries" name="do_action" type="object" class="oe_highlight"/> + <button string="Cancel" class="btn btn-secondary" special="cancel"/> + </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="res_model">account.automatic.entry.wizard</field> + <field name="view_mode">form</field> + <field name="target">new</field> + </record> + </data> +</odoo> diff --git a/addons/account/wizard/account_invoice_send.py b/addons/account/wizard/account_invoice_send.py new file mode 100644 index 00000000..b9aae09e --- /dev/null +++ b/addons/account/wizard/account_invoice_send.py @@ -0,0 +1,134 @@ +# -*- 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', index=True, + domain="[('model', '=', 'account.move')]" + ) + + @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('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).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}) + + 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 = active_records.mapped('partner_id.lang') + default_lang = get_lang(self.env) + for lang in (set(langs) or [default_lang]): + 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=lang) + self_lang.onchange_template_id() + self_lang._send_email() + else: + self._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.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 diff --git a/addons/account/wizard/account_invoice_send_views.xml b/addons/account/wizard/account_invoice_send_views.xml new file mode 100644 index 00000000..518aed6d --- /dev/null +++ b/addons/account/wizard/account_invoice_send_views.xml @@ -0,0 +1,94 @@ +<?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"> + <!-- 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"> + Preview as a PDF + </div> + </div> + <div name="option_email"> + <field name="is_email" /> + <b><label for="is_email"/></b> + </div> + <div class="text-left 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" style="border:none;" 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)]}" + name="send_and_print_action" type="object" class="send_and_print btn-primary o_mail_send"/> + <button string="Send" + 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" + 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" /> + <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="pull-right btn-secondary" help="Save as a new template" /> + </footer> + </form> + </field> + </record> + + <record id="invoice_send" model="ir.actions.act_window"> + <field name="name">Send & print</field> + <field name="res_model">account.invoice.send</field> + <field name="view_mode">form</field> + <field name="target">new</field> + <field name="context" eval="{ + 'default_template_id': ref('account.email_template_edi_invoice'), + 'mark_invoice_as_sent': True, + 'custom_layout': 'mail.mail_notification_paynow', + }"/> + <field name="binding_model_id" ref="model_account_move"/> + <field name="binding_view_types">list</field> + </record> + + </data> + +</odoo> diff --git a/addons/account/wizard/account_move_reversal.py b/addons/account/wizard/account_move_reversal.py new file mode 100644 index 00000000..7724ca44 --- /dev/null +++ b/addons/account/wizard/account_move_reversal.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api +from odoo.tools.translate import _ +from odoo.exceptions import UserError + + +class AccountMoveReversal(models.TransientModel): + """ + Account move reversal wizard, it cancel an account move by reversing it. + """ + _name = 'account.move.reversal' + _description = 'Account Move Reversal' + _check_company_auto = True + + 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.') + journal_id = fields.Many2one('account.journal', string='Use Specific Journal', help='If empty, uses the journal of the journal entry to be reversed.', check_company=True) + company_id = fields.Many2one('res.company', required=True, readonly=True) + + # computed fields + residual = fields.Monetary(compute="_compute_from_moves") + currency_id = fields.Many2one('res.currency', compute="_compute_from_moves") + move_type = fields.Char(compute="_compute_from_moves") + + @api.model + def default_get(self, fields): + 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 any(move.state != "posted" for move in move_ids): + raise UserError(_('You can only reverse posted moves.')) + 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') + def _compute_from_moves(self): + for record in self: + move_ids = record.move_ids._origin + record.residual = len(move_ids) == 1 and move_ids.amount_residual or 0 + record.currency_id = len(move_ids.currency_id) == 1 and move_ids.currency_id or False + 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 + return { + 'ref': _('Reversal of: %(move_name)s, %(reason)s', move_name=move.name, reason=self.reason) + if self.reason + else _('Reversal of: %s', move.name), + 'date': reverse_date, + 'invoice_date': move.is_invoice(include_receipts=True) and (self.date or move.date) or False, + 'journal_id': self.journal_id and self.journal_id.id or move.journal_id.id, + 'invoice_payment_term_id': None, + 'invoice_user_id': move.invoice_user_id.id, + 'auto_post': True if reverse_date > fields.Date.context_today(self) else False, + } + + def _reverse_moves_post_hook(self, moves): + # DEPRECATED: TO REMOVE IN MASTER + return + + def reverse_moves(self): + self.ensure_one() + moves = self.move_ids + + # Create default values. + default_values_list = [] + for move in moves: + default_values_list.append(self._prepare_default_reversal(move)) + + batches = [ + [self.env['account.move'], [], True], # Moves to be cancelled by the reverses. + [self.env['account.move'], [], False], # Others. + ] + for move, default_vals in zip(moves, default_values_list): + is_auto_post = bool(default_vals.get('auto_post')) + is_cancel_needed = not is_auto_post and self.refund_method in ('cancel', 'modify') + batch_index = 0 if is_cancel_needed else 1 + batches[batch_index][0] |= move + batches[batch_index][1].append(default_vals) + + # Handle reverse method. + 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) + + if self.refund_method == '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]) + new_moves = self.env['account.move'].create(moves_vals_list) + + moves_to_redirect |= new_moves + + self.new_move_ids = moves_to_redirect + + # Create action. + action = { + 'name': _('Reverse Moves'), + 'type': 'ir.actions.act_window', + 'res_model': 'account.move', + } + if len(moves_to_redirect) == 1: + action.update({ + 'view_mode': 'form', + 'res_id': moves_to_redirect.id, + }) + else: + action.update({ + 'view_mode': 'tree,form', + 'domain': [('id', 'in', moves_to_redirect.ids)], + }) + return action diff --git a/addons/account/wizard/account_move_reversal_view.xml b/addons/account/wizard/account_move_reversal_view.xml new file mode 100644 index 00000000..2939a3f7 --- /dev/null +++ b/addons/account/wizard/account_move_reversal_view.xml @@ -0,0 +1,60 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + <record id="view_account_move_reversal" model="ir.ui.view"> + <field name="name">account.move.reversal.form</field> + <field name="model">account.move.reversal</field> + <field name="arch" type="xml"> + <form string="Reverse Journal Entry"> + <field name="residual" invisible="1"/> + <field name="company_id" invisible="1"/> + <field name="move_ids" invisible="1"/> + <field name="move_type" invisible="1"/> + <group> + <group attrs="{'invisible': [('move_type', 'not in', ('out_invoice', 'in_invoice'))]}"> + <field name="refund_method" widget="radio" attrs="{'readonly': [('residual', '=', 0)]}"/> + </group> + <group attrs="{'invisible': [('move_type', 'not in', ('out_invoice', 'in_invoice', 'some_invoice'))]}"> + <div attrs="{'invisible':[('refund_method', '!=', 'refund')]}" class="oe_grey" colspan="4"> + 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="4"> + The credit note is auto-validated and reconciled with the invoice. + </div> + <div attrs="{'invisible':[('refund_method', '!=', 'modify')]}" class="oe_grey" colspan="4"> + 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')], 'reason': [('move_type', '==', 'entry')]}"/> + <field name="date_mode" string="Reversal Date" widget="radio"/> + </group> + <group> + <field name="journal_id"/> + <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> + </group> + <footer> + <button string='Reverse' name="reverse_moves" type="object" class="btn-primary"/> + <button string="Cancel" class="btn-secondary" special="cancel"/> + </footer> + </form> + </field> + </record> + + <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_id" ref="view_account_move_reversal"/> + <field name="target">new</field> + <field name="groups_id" eval="[(4, ref('account.group_account_invoice'))]"/> + <field name="binding_model_id" ref="account.model_account_move" /> + <field name="binding_view_types">list</field> + </record> + </data> +</odoo> diff --git a/addons/account/wizard/account_payment_register.py b/addons/account/wizard/account_payment_register.py new file mode 100644 index 00000000..98bed601 --- /dev/null +++ b/addons/account/wizard/account_payment_register.py @@ -0,0 +1,535 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + + +class AccountPaymentRegister(models.TransientModel): + _name = 'account.payment.register' + _description = 'Register Payment' + + # == Business fields == + payment_date = fields.Date(string="Payment Date", required=True, + default=fields.Date.context_today) + amount = fields.Monetary(currency_field='currency_id', store=True, readonly=False, + compute='_compute_amount') + communication = fields.Char(string="Memo", store=True, readonly=False, + compute='_compute_communication') + group_payment = fields.Boolean(string="Group Payments", store=True, readonly=False, + compute='_compute_group_payment', + help="Only one payment will be created by partner (bank)/ currency.") + currency_id = fields.Many2one('res.currency', string='Currency', store=True, readonly=False, + compute='_compute_currency_id', + help="The payment's currency.") + journal_id = fields.Many2one('account.journal', store=True, readonly=False, + compute='_compute_journal_id', + domain="[('company_id', '=', company_id), ('type', 'in', ('bank', 'cash'))]") + partner_bank_id = fields.Many2one('res.partner.bank', string="Recipient Bank Account", + readonly=False, store=True, + compute='_compute_partner_bank_id', + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id), ('partner_id', '=', partner_id)]") + company_currency_id = fields.Many2one('res.currency', string="Company Currency", + related='company_id.currency_id') + + # == Fields given through the context == + line_ids = fields.Many2many('account.move.line', 'account_payment_register_move_line_rel', 'wizard_id', 'line_id', + string="Journal items", readonly=True, copy=False,) + payment_type = fields.Selection([ + ('outbound', 'Send Money'), + ('inbound', 'Receive Money'), + ], string='Payment Type', store=True, copy=False, + compute='_compute_from_lines') + partner_type = fields.Selection([ + ('customer', 'Customer'), + ('supplier', 'Vendor'), + ], store=True, copy=False, + compute='_compute_from_lines') + source_amount = fields.Monetary( + string="Amount to Pay (company currency)", store=True, copy=False, + currency_field='company_currency_id', + compute='_compute_from_lines') + source_amount_currency = fields.Monetary( + string="Amount to Pay (foreign currency)", store=True, copy=False, + currency_field='source_currency_id', + compute='_compute_from_lines') + source_currency_id = fields.Many2one('res.currency', + string='Source Currency', store=True, copy=False, + compute='_compute_from_lines', + help="The payment's currency.") + can_edit_wizard = fields.Boolean(store=True, copy=False, + compute='_compute_from_lines', + help="Technical field used to indicate the user can edit the wizard content such as the amount.") + can_group_payments = fields.Boolean(store=True, copy=False, + compute='_compute_from_lines', + help="Technical field used to indicate the user can see the 'group_payments' box.") + company_id = fields.Many2one('res.company', store=True, copy=False, + compute='_compute_from_lines') + partner_id = fields.Many2one('res.partner', + string="Customer/Vendor", store=True, copy=False, ondelete='restrict', + compute='_compute_from_lines') + + # == Payment methods fields == + payment_method_id = fields.Many2one('account.payment.method', string='Payment Method', + readonly=False, store=True, + compute='_compute_payment_method_id', + domain="[('id', 'in', available_payment_method_ids)]", + help="Manual: Get paid by cash, check or any other method outside of Odoo.\n"\ + "Electronic: Get paid automatically through a payment acquirer by requesting a transaction on a card saved by the customer when buying or subscribing online (payment token).\n"\ + "Check: Pay bill by check and print it from Odoo.\n"\ + "Batch Deposit: Encase several customer checks at once by generating a batch deposit to submit to your bank. When encoding the bank statement in Odoo, you are suggested to reconcile the transaction with the batch deposit.To enable batch deposit, module account_batch_payment must be installed.\n"\ + "SEPA Credit Transfer: Pay bill from a SEPA Credit Transfer file you submit to your bank. To enable sepa credit transfer, module account_sepa must be installed ") + available_payment_method_ids = fields.Many2many('account.payment.method', + compute='_compute_payment_method_fields') + hide_payment_method = fields.Boolean( + compute='_compute_payment_method_fields', + help="Technical field used to hide the payment method if the selected journal has only one available which is 'manual'") + + # == Payment difference fields == + payment_difference = fields.Monetary( + compute='_compute_payment_difference') + payment_difference_handling = fields.Selection([ + ('open', 'Keep open'), + ('reconcile', 'Mark as fully paid'), + ], default='open', string="Payment Difference Handling") + writeoff_account_id = fields.Many2one('account.account', string="Difference Account", copy=False, + domain="[('deprecated', '=', False), ('company_id', '=', company_id)]") + writeoff_label = fields.Char(string='Journal Item Label', default='Write-Off', + help='Change label of the counterpart that will hold the payment difference') + + # == Display purpose fields == + show_partner_bank_account = fields.Boolean( + compute='_compute_show_require_partner_bank', + help="Technical field used to know whether the field `partner_bank_id` needs to be displayed or not in the payments form views") + require_partner_bank_account = fields.Boolean( + compute='_compute_show_require_partner_bank', + help="Technical field used to know whether the field `partner_bank_id` needs to be required or not in the payments form views") + country_code = fields.Char(related='company_id.country_id.code', readonly=True) + + # ------------------------------------------------------------------------- + # HELPERS + # ------------------------------------------------------------------------- + + @api.model + def _get_batch_communication(self, batch_result): + ''' Helper to compute the communication based on the batch. + :param batch_result: A batch returned by '_get_batches'. + :return: A string representing a communication to be set on payment. + ''' + labels = set(line.name or line.move_id.ref or line.move_id.name for line in batch_result['lines']) + return ' '.join(sorted(labels)) + + @api.model + def _get_line_batch_key(self, line): + ''' Turn the line passed as parameter to a dictionary defining on which way the lines + will be grouped together. + :return: A python dictionary. + ''' + return { + 'partner_id': line.partner_id.id, + 'account_id': line.account_id.id, + 'currency_id': (line.currency_id or line.company_currency_id).id, + 'partner_bank_id': (line.move_id.partner_bank_id or line.partner_id.commercial_partner_id.bank_ids[:1]).id, + 'partner_type': 'customer' if line.account_internal_type == 'receivable' else 'supplier', + 'payment_type': 'inbound' if line.balance > 0.0 else 'outbound', + } + + def _get_batches(self): + ''' Group the account.move.line linked to the wizard together. + :return: A list of batches, each one containing: + * key_values: The key as a dictionary used to group the journal items together. + * moves: An account.move recordset. + ''' + self.ensure_one() + + lines = self.line_ids._origin + + if len(lines.company_id) > 1: + raise UserError(_("You can't create payments for entries belonging to different companies.")) + if not lines: + raise UserError(_("You can't open the register payment wizard without at least one receivable/payable line.")) + + batches = {} + for line in lines: + batch_key = self._get_line_batch_key(line) + + serialized_key = '-'.join(str(v) for v in batch_key.values()) + batches.setdefault(serialized_key, { + 'key_values': batch_key, + 'lines': self.env['account.move.line'], + }) + batches[serialized_key]['lines'] += line + return list(batches.values()) + + @api.model + def _get_wizard_values_from_batch(self, batch_result): + ''' Extract values from the batch passed as parameter (see '_get_batches') + to be mounted in the wizard view. + :param batch_result: A batch returned by '_get_batches'. + :return: A dictionary containing valid fields + ''' + key_values = batch_result['key_values'] + lines = batch_result['lines'] + company = lines[0].company_id + + source_amount = abs(sum(lines.mapped('amount_residual'))) + if key_values['currency_id'] == company.currency_id.id: + source_amount_currency = source_amount + else: + source_amount_currency = abs(sum(lines.mapped('amount_residual_currency'))) + + return { + 'company_id': company.id, + 'partner_id': key_values['partner_id'], + 'partner_type': key_values['partner_type'], + 'payment_type': key_values['payment_type'], + 'source_currency_id': key_values['currency_id'], + 'source_amount': source_amount, + 'source_amount_currency': source_amount_currency, + } + + # ------------------------------------------------------------------------- + # COMPUTE METHODS + # ------------------------------------------------------------------------- + + @api.depends('line_ids') + def _compute_from_lines(self): + ''' Load initial values from the account.moves passed through the context. ''' + for wizard in self: + batches = wizard._get_batches() + batch_result = batches[0] + wizard_values_from_batch = wizard._get_wizard_values_from_batch(batch_result) + + if len(batches) == 1: + # == Single batch to be mounted on the view == + wizard.update(wizard_values_from_batch) + + wizard.can_edit_wizard = True + wizard.can_group_payments = len(batch_result['lines']) != 1 + else: + # == Multiple batches: The wizard is not editable == + wizard.update({ + 'company_id': batches[0]['lines'][0].company_id.id, + 'partner_id': False, + 'partner_type': False, + 'payment_type': wizard_values_from_batch['payment_type'], + 'source_currency_id': False, + 'source_amount': False, + 'source_amount_currency': False, + }) + + wizard.can_edit_wizard = False + wizard.can_group_payments = any(len(batch_result['lines']) != 1 for batch_result in batches) + + @api.depends('can_edit_wizard') + def _compute_communication(self): + # The communication can't be computed in '_compute_from_lines' because + # it's a compute editable field and then, should be computed in a separated method. + for wizard in self: + if wizard.can_edit_wizard: + batches = self._get_batches() + wizard.communication = wizard._get_batch_communication(batches[0]) + else: + wizard.communication = False + + @api.depends('can_edit_wizard') + def _compute_group_payment(self): + for wizard in self: + if wizard.can_edit_wizard: + batches = wizard._get_batches() + wizard.group_payment = len(batches[0]['lines'].move_id) == 1 + else: + wizard.group_payment = False + + @api.depends('company_id', 'source_currency_id') + def _compute_journal_id(self): + for wizard in self: + domain = [ + ('type', 'in', ('bank', 'cash')), + ('company_id', '=', wizard.company_id.id), + ] + journal = None + if wizard.source_currency_id: + journal = self.env['account.journal'].search(domain + [('currency_id', '=', wizard.source_currency_id.id)], limit=1) + if not journal: + journal = self.env['account.journal'].search(domain, limit=1) + wizard.journal_id = journal + + @api.depends('journal_id') + def _compute_currency_id(self): + for wizard in self: + wizard.currency_id = wizard.journal_id.currency_id or wizard.source_currency_id or wizard.company_id.currency_id + + @api.depends('partner_id') + def _compute_partner_bank_id(self): + ''' The default partner_bank_id will be the first available on the partner. ''' + for wizard in self: + available_partner_bank_accounts = wizard.partner_id.bank_ids.filtered(lambda x: x.company_id in (False, wizard.company_id)) + if available_partner_bank_accounts: + wizard.partner_bank_id = available_partner_bank_accounts[0]._origin + else: + wizard.partner_bank_id = False + + @api.depends('payment_type', + 'journal_id.inbound_payment_method_ids', + 'journal_id.outbound_payment_method_ids') + def _compute_payment_method_fields(self): + for wizard in self: + if wizard.payment_type == 'inbound': + wizard.available_payment_method_ids = wizard.journal_id.inbound_payment_method_ids + else: + wizard.available_payment_method_ids = wizard.journal_id.outbound_payment_method_ids + + wizard.hide_payment_method = len(wizard.available_payment_method_ids) == 1 and wizard.available_payment_method_ids.code == 'manual' + + @api.depends('payment_type', + 'journal_id.inbound_payment_method_ids', + 'journal_id.outbound_payment_method_ids') + def _compute_payment_method_id(self): + for wizard in self: + if wizard.payment_type == 'inbound': + available_payment_methods = wizard.journal_id.inbound_payment_method_ids + else: + available_payment_methods = wizard.journal_id.outbound_payment_method_ids + + # Select the first available one by default. + if available_payment_methods: + wizard.payment_method_id = available_payment_methods[0]._origin + else: + wizard.payment_method_id = False + + @api.depends('payment_method_id') + def _compute_show_require_partner_bank(self): + """ Computes if the destination bank account must be displayed in the payment form view. By default, it + won't be displayed but some modules might change that, depending on the payment type.""" + for wizard in self: + wizard.show_partner_bank_account = wizard.payment_method_id.code in self.env['account.payment']._get_method_codes_using_bank_account() + wizard.require_partner_bank_account = wizard.payment_method_id.code in self.env['account.payment']._get_method_codes_needing_bank_account() + + @api.depends('source_amount', 'source_amount_currency', 'source_currency_id', 'company_id', 'currency_id', 'payment_date') + def _compute_amount(self): + for wizard in self: + if wizard.source_currency_id == wizard.currency_id: + # Same currency. + wizard.amount = wizard.source_amount_currency + elif wizard.currency_id == wizard.company_id.currency_id: + # Payment expressed on the company's currency. + wizard.amount = wizard.source_amount + else: + # Foreign currency on payment different than the one set on the journal entries. + amount_payment_currency = wizard.company_id.currency_id._convert(wizard.source_amount, wizard.currency_id, wizard.company_id, wizard.payment_date) + wizard.amount = amount_payment_currency + + @api.depends('amount') + def _compute_payment_difference(self): + for wizard in self: + if wizard.source_currency_id == wizard.currency_id: + # Same currency. + wizard.payment_difference = wizard.source_amount_currency - wizard.amount + elif wizard.currency_id == wizard.company_id.currency_id: + # Payment expressed on the company's currency. + wizard.payment_difference = wizard.source_amount - wizard.amount + else: + # Foreign currency on payment different than the one set on the journal entries. + amount_payment_currency = wizard.company_id.currency_id._convert(wizard.source_amount, wizard.currency_id, wizard.company_id, wizard.payment_date) + wizard.payment_difference = amount_payment_currency - wizard.amount + + # ------------------------------------------------------------------------- + # LOW-LEVEL METHODS + # ------------------------------------------------------------------------- + + @api.model + def default_get(self, fields_list): + # OVERRIDE + res = super().default_get(fields_list) + + if 'line_ids' in fields_list and 'line_ids' not in res: + + # Retrieve moves to pay from the context. + + if self._context.get('active_model') == 'account.move': + lines = self.env['account.move'].browse(self._context.get('active_ids', [])).line_ids + elif self._context.get('active_model') == 'account.move.line': + lines = self.env['account.move.line'].browse(self._context.get('active_ids', [])) + else: + raise UserError(_( + "The register payment wizard should only be called on account.move or account.move.line records." + )) + + # Keep lines having a residual amount to pay. + available_lines = self.env['account.move.line'] + for line in lines: + if line.move_id.state != 'posted': + raise UserError(_("You can only register payment for posted journal entries.")) + + if line.account_internal_type not in ('receivable', 'payable'): + continue + if line.currency_id: + if line.currency_id.is_zero(line.amount_residual_currency): + continue + else: + if line.company_currency_id.is_zero(line.amount_residual): + continue + available_lines |= line + + # Check. + if not available_lines: + raise UserError(_("You can't register a payment because there is nothing left to pay on the selected journal items.")) + if len(lines.company_id) > 1: + raise UserError(_("You can't create payments for entries belonging to different companies.")) + if len(set(available_lines.mapped('account_internal_type'))) > 1: + raise UserError(_("You can't register payments for journal items being either all inbound, either all outbound.")) + + res['line_ids'] = [(6, 0, available_lines.ids)] + + return res + + # ------------------------------------------------------------------------- + # BUSINESS METHODS + # ------------------------------------------------------------------------- + + def _create_payment_vals_from_wizard(self): + payment_vals = { + 'date': self.payment_date, + 'amount': self.amount, + 'payment_type': self.payment_type, + 'partner_type': self.partner_type, + 'ref': self.communication, + 'journal_id': self.journal_id.id, + 'currency_id': self.currency_id.id, + 'partner_id': self.partner_id.id, + 'partner_bank_id': self.partner_bank_id.id, + 'payment_method_id': self.payment_method_id.id, + 'destination_account_id': self.line_ids[0].account_id.id + } + + if not self.currency_id.is_zero(self.payment_difference) and self.payment_difference_handling == 'reconcile': + payment_vals['write_off_line_vals'] = { + 'name': self.writeoff_label, + 'amount': self.payment_difference, + 'account_id': self.writeoff_account_id.id, + } + return payment_vals + + def _create_payment_vals_from_batch(self, batch_result): + batch_values = self._get_wizard_values_from_batch(batch_result) + return { + 'date': self.payment_date, + 'amount': batch_values['source_amount_currency'], + 'payment_type': batch_values['payment_type'], + 'partner_type': batch_values['partner_type'], + 'ref': self._get_batch_communication(batch_result), + 'journal_id': self.journal_id.id, + 'currency_id': batch_values['source_currency_id'], + 'partner_id': batch_values['partner_id'], + 'partner_bank_id': batch_result['key_values']['partner_bank_id'], + 'payment_method_id': self.payment_method_id.id, + 'destination_account_id': batch_result['lines'][0].account_id.id + } + + def _create_payments(self): + self.ensure_one() + batches = self._get_batches() + edit_mode = self.can_edit_wizard and (len(batches[0]['lines']) == 1 or self.group_payment) + + to_reconcile = [] + if edit_mode: + payment_vals = self._create_payment_vals_from_wizard() + payment_vals_list = [payment_vals] + to_reconcile.append(batches[0]['lines']) + else: + # Don't group payments: Create one batch per move. + if not self.group_payment: + new_batches = [] + for batch_result in batches: + for line in batch_result['lines']: + new_batches.append({ + **batch_result, + 'lines': line, + }) + batches = new_batches + + payment_vals_list = [] + for batch_result in batches: + payment_vals_list.append(self._create_payment_vals_from_batch(batch_result)) + to_reconcile.append(batch_result['lines']) + + payments = self.env['account.payment'].create(payment_vals_list) + + # If payments are made using a currency different than the source one, ensure the balance match exactly in + # order to fully paid the source journal items. + # For example, suppose a new currency B having a rate 100:1 regarding the company currency A. + # If you try to pay 12.15A using 0.12B, the computed balance will be 12.00A for the payment instead of 12.15A. + if edit_mode: + for payment, lines in zip(payments, to_reconcile): + # Batches are made using the same currency so making 'lines.currency_id' is ok. + if payment.currency_id != lines.currency_id: + liquidity_lines, counterpart_lines, writeoff_lines = payment._seek_for_lines() + source_balance = abs(sum(lines.mapped('amount_residual'))) + payment_rate = liquidity_lines[0].amount_currency / liquidity_lines[0].balance + source_balance_converted = abs(source_balance) * payment_rate + + # Translate the balance into the payment currency is order to be able to compare them. + # In case in both have the same value (12.15 * 0.01 ~= 0.12 in our example), it means the user + # attempt to fully paid the source lines and then, we need to manually fix them to get a perfect + # match. + payment_balance = abs(sum(counterpart_lines.mapped('balance'))) + payment_amount_currency = abs(sum(counterpart_lines.mapped('amount_currency'))) + if not payment.currency_id.is_zero(source_balance_converted - payment_amount_currency): + continue + + delta_balance = source_balance - payment_balance + + # Balance are already the same. + if self.company_currency_id.is_zero(delta_balance): + continue + + # Fix the balance but make sure to peek the liquidity and counterpart lines first. + debit_lines = (liquidity_lines + counterpart_lines).filtered('debit') + credit_lines = (liquidity_lines + counterpart_lines).filtered('credit') + + payment.move_id.write({'line_ids': [ + (1, debit_lines[0].id, {'debit': debit_lines[0].debit + delta_balance}), + (1, credit_lines[0].id, {'credit': credit_lines[0].credit + delta_balance}), + ]}) + + payments.action_post() + + domain = [('account_internal_type', 'in', ('receivable', 'payable')), ('reconciled', '=', False)] + for payment, lines in zip(payments, to_reconcile): + + # When using the payment tokens, the payment could not be posted at this point (e.g. the transaction failed) + # and then, we can't perform the reconciliation. + if payment.state != 'posted': + continue + + payment_lines = payment.line_ids.filtered_domain(domain) + for account in payment_lines.account_id: + (payment_lines + lines)\ + .filtered_domain([('account_id', '=', account.id), ('reconciled', '=', False)])\ + .reconcile() + + return payments + + def action_create_payments(self): + payments = self._create_payments() + + if self._context.get('dont_redirect_to_payments'): + return True + + action = { + 'name': _('Payments'), + 'type': 'ir.actions.act_window', + 'res_model': 'account.payment', + 'context': {'create': False}, + } + if len(payments) == 1: + action.update({ + 'view_mode': 'form', + 'res_id': payments.id, + }) + else: + action.update({ + 'view_mode': 'tree,form', + 'domain': [('id', 'in', payments.ids)], + }) + return action diff --git a/addons/account/wizard/account_payment_register_views.xml b/addons/account/wizard/account_payment_register_views.xml new file mode 100644 index 00000000..16eec30e --- /dev/null +++ b/addons/account/wizard/account_payment_register_views.xml @@ -0,0 +1,83 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + + <record id="view_account_payment_register_form" model="ir.ui.view"> + <field name="name">account.payment.register.form</field> + <field name="model">account.payment.register</field> + <field name="arch" type="xml"> + <form string="Register Payment"> + <!-- 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="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"/> + <field name="source_amount_currency" invisible="1" force_save="1"/> + <field name="source_currency_id" invisible="1" force_save="1"/> + <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="show_partner_bank_account" invisible="1"/> + <field name="require_partner_bank_account" invisible="1"/> + <field name="hide_payment_method" invisible="1"/> + <field name="available_payment_method_ids" invisible="1"/> + <field name="company_currency_id" invisible="1"/> + + <group> + <group name="group1"> + <field name="journal_id" widget="selection" required="1"/> + <field name="payment_method_id" widget="radio" + required="1" + attrs="{'invisible': [('hide_payment_method', '=', 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)]}"/> + <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" + 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), '|', ('can_edit_wizard', '=', False), '&', ('can_group_payments', '=', True), ('group_payment', '=', False)]}" + groups="account.group_account_readonly"> + <label for="payment_difference"/> + <div> + <field name="payment_difference"/> + <field name="payment_difference_handling" widget="radio" nolabel="1"/> + <div attrs="{'invisible': [('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')]}"/> + <label for="writeoff_label" class="oe_edit_only" string="Label"/> + <field name="writeoff_label" attrs="{'required': [('payment_difference_handling', '=', 'reconcile')]}"/> + </div> + </div> + </group> + </group> + <footer> + <button string="Create Payment" name="action_create_payments" type="object" class="oe_highlight"/> + <button string="Cancel" class="btn btn-secondary" special="cancel"/> + </footer> + </form> + </field> + </record> + + </data> +</odoo> diff --git a/addons/account/wizard/account_report_common.py b/addons/account/wizard/account_report_common.py new file mode 100644 index 00000000..e6317dae --- /dev/null +++ b/addons/account/wizard/account_report_common.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _ +from odoo.tools.misc import get_lang + + +class AccountCommonReport(models.TransientModel): + _name = "account.common.report" + _description = "Account Common Report" + + company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company) + journal_ids = fields.Many2many('account.journal', string='Journals', required=True, default=lambda self: self.env['account.journal'].search([('company_id', '=', self.company_id.id)])) + date_from = fields.Date(string='Start Date') + date_to = fields.Date(string='End Date') + target_move = fields.Selection([('posted', 'All Posted Entries'), + ('all', 'All Entries'), + ], string='Target Moves', required=True, default='posted') + + @api.onchange('company_id') + def _onchange_company_id(self): + if self.company_id: + self.journal_ids = self.env['account.journal'].search( + [('company_id', '=', self.company_id.id)]) + else: + self.journal_ids = self.env['account.journal'].search([]) + + def _build_contexts(self, data): + result = {} + result['journal_ids'] = 'journal_ids' in data['form'] and data['form']['journal_ids'] or False + result['state'] = 'target_move' in data['form'] and data['form']['target_move'] or '' + result['date_from'] = data['form']['date_from'] or False + result['date_to'] = data['form']['date_to'] or False + result['strict_range'] = True if result['date_from'] else False + result['company_id'] = data['form']['company_id'][0] or False + return result + + def _print_report(self, data): + raise NotImplementedError() + + def check_report(self): + self.ensure_one() + data = {} + data['ids'] = self.env.context.get('active_ids', []) + data['model'] = self.env.context.get('active_model', 'ir.ui.menu') + data['form'] = self.read(['date_from', 'date_to', 'journal_ids', 'target_move', 'company_id'])[0] + used_context = self._build_contexts(data) + data['form']['used_context'] = dict(used_context, lang=get_lang(self.env).code) + return self.with_context(discard_logo_check=True)._print_report(data) diff --git a/addons/account/wizard/account_report_common_journal.py b/addons/account/wizard/account_report_common_journal.py new file mode 100644 index 00000000..c00de454 --- /dev/null +++ b/addons/account/wizard/account_report_common_journal.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models + + +class AccountCommonJournalReport(models.TransientModel): + _name = 'account.common.journal.report' + _description = 'Common Journal Report' + _inherit = "account.common.report" + + amount_currency = fields.Boolean('With Currency', help="Print Report with the currency column if the currency differs from the company currency.") + + def pre_print_report(self, data): + data['form'].update({'amount_currency': self.amount_currency}) + return data diff --git a/addons/account/wizard/account_report_common_view.xml b/addons/account/wizard/account_report_common_view.xml new file mode 100644 index 00000000..6333f125 --- /dev/null +++ b/addons/account/wizard/account_report_common_view.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + + <record id="account_common_report_view" model="ir.ui.view"> + <field name="name">Common Report</field> + <field name="model">account.common.report</field> + <field name="arch" type="xml"> + <form string="Report Options"> + <group col="4"> + <field name="target_move" widget="radio"/> + <field name="date_from"/> + <field name="date_to"/> + </group> + <group> + <field name="journal_ids" widget="many2many_tags" options="{'no_create': True}"/> + <field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/> + </group> + <footer> + <button name="check_report" string="Print" type="object" default_focus="1" class="oe_highlight"/> + <button string="Cancel" class="btn btn-secondary" special="cancel" /> + </footer> + </form> + </field> + </record> + +</odoo> diff --git a/addons/account/wizard/account_report_print_journal.py b/addons/account/wizard/account_report_print_journal.py new file mode 100644 index 00000000..fee61441 --- /dev/null +++ b/addons/account/wizard/account_report_print_journal.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- + +from odoo import fields, models + + +class AccountPrintJournal(models.TransientModel): + _inherit = "account.common.journal.report" + _name = "account.print.journal" + _description = "Account Print Journal" + + sort_selection = fields.Selection([('date', 'Date'), ('move_name', 'Journal Entry Number'),], 'Entries Sorted by', required=True, default='move_name') + journal_ids = fields.Many2many('account.journal', string='Journals', required=True, default=lambda self: self.env['account.journal'].search([('type', 'in', ['sale', 'purchase'])])) + + def _print_report(self, data): + data = self.pre_print_report(data) + data['form'].update({'sort_selection': self.sort_selection}) + return self.env.ref('account.action_report_journal').with_context(landscape=True).report_action(self, data=data) diff --git a/addons/account/wizard/account_resequence.py b/addons/account/wizard/account_resequence.py new file mode 100644 index 00000000..1e0f2006 --- /dev/null +++ b/addons/account/wizard/account_resequence.py @@ -0,0 +1,145 @@ +# -*- 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.misc import format_date + +import re +from collections import defaultdict +import json + + +class ReSequenceWizard(models.TransientModel): + _name = 'account.resequence.wizard' + _description = 'Remake the sequence of Journal Entries.' + + sequence_number_reset = fields.Char(compute='_compute_sequence_number_reset') + first_date = fields.Date(help="Date (inclusive) from which the numbers are resequenced.") + end_date = fields.Date(help="Date (inclusive) to which the numbers are resequenced. If not set, all Journal Entries up to the end of the period are resequenced.") + first_name = fields.Char(compute="_compute_first_name", readonly=False, store=True, required=True, string="First New Sequence") + ordering = fields.Selection([('keep', 'Keep current order'), ('date', 'Reorder by accounting date')], required=True, default='keep') + move_ids = fields.Many2many('account.move') + new_values = fields.Text(compute='_compute_new_values') + preview_moves = fields.Text(compute='_compute_preview_moves') + + @api.model + def default_get(self, fields_list): + values = super(ReSequenceWizard, self).default_get(fields_list) + active_move_ids = self.env['account.move'] + if self.env.context['active_model'] == 'account.move' and 'active_ids' in self.env.context: + active_move_ids = self.env['account.move'].browse(self.env.context['active_ids']) + if len(active_move_ids.journal_id) > 1: + raise UserError(_('You can only resequence items from the same journal')) + move_types = set(active_move_ids.mapped('move_type')) + if ( + active_move_ids.journal_id.refund_sequence + and ('in_refund' in move_types or 'out_refund' in move_types) + 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.')) + values['move_ids'] = [(6, 0, active_move_ids.ids)] + return values + + @api.depends('first_name') + def _compute_sequence_number_reset(self): + for record in self: + record.sequence_number_reset = record.move_ids[0]._deduce_sequence_number_reset(record.first_name) + + @api.depends('move_ids') + def _compute_first_name(self): + self.first_name = "" + for record in self: + if record.move_ids: + record.first_name = min(record.move_ids._origin.mapped('name')) + + @api.depends('new_values', 'ordering') + def _compute_preview_moves(self): + """Reduce the computed new_values to a smaller set to display in the preview.""" + for record in self: + new_values = sorted(json.loads(record.new_values).values(), key=lambda x: x['server-date'], reverse=True) + changeLines = [] + in_elipsis = 0 + previous_line = None + for i, line in enumerate(new_values): + if i < 3 or i == len(new_values) - 1 or line['new_by_name'] != line['new_by_date'] \ + or (self.sequence_number_reset == 'year' and line['server-date'][0:4] != previous_line['server-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': '...'}) + in_elipsis = 0 + changeLines.append(line) + else: + in_elipsis += 1 + previous_line = line + + record.preview_moves = json.dumps({ + 'ordering': record.ordering, + 'changeLines': changeLines, + }) + + @api.depends('first_name', 'move_ids', 'sequence_number_reset') + def _compute_new_values(self): + """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 + relative to the preview widget. + """ + def _get_move_key(move_id): + if self.sequence_number_reset == 'year': + return move_id.date.year + elif self.sequence_number_reset == 'month': + return (move_id.date.year, move_id.date.month) + return 'default' + + self.new_values = "{}" + for record in self.filtered('first_name'): + moves_by_period = defaultdict(lambda: record.env['account.move']) + for move in record.move_ids._origin: # Sort the moves by period depending on the sequence number reset + moves_by_period[_get_move_key(move)] += move + + format, format_values = self.env['account.move']._get_sequence_format_param(record.first_name) + + new_values = {} + for j, period_recs in enumerate(moves_by_period.values()): + # compute the new values period by period + for move in period_recs: + new_values[move.id] = { + 'id': move.id, + 'current_name': move.name, + 'state': move.state, + 'date': format_date(self.env, move.date), + 'server-date': str(move.date), + } + + new_name_list = [format.format(**{ + **format_values, + 'year': period_recs[0].date.year % (10 ** format_values['year_length']), + 'month': period_recs[0].date.month, + '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 + for move, new_name in zip(period_recs.sorted(lambda m: (m.sequence_prefix, m.sequence_number)), new_name_list): + new_values[move.id]['new_by_name'] = new_name + # For all the moves of this period, assign the name by increasing date + for move, new_name in zip(period_recs.sorted(lambda m: (m.date, m.name, m.id)), new_name_list): + new_values[move.id]['new_by_date'] = new_name + + record.new_values = json.dumps(new_values) + + def resequence(self): + new_values = json.loads(self.new_values) + # Can't change the name of a posted invoice, but we do not want to have the chatter + # logging 3 separate changes with [state to draft], [change of name], [state to posted] + self.with_context(tracking_disable=True).move_ids.state = 'draft' + if self.move_ids.journal_id and self.move_ids.journal_id.restrict_mode_hash_table: + if self.ordering == 'date': + raise UserError(_('You can not reorder sequence by date when the journal is locked with a hash.')) + for move_id in self.move_ids: + if str(move_id.id) in new_values: + if self.ordering == 'keep': + move_id.name = new_values[str(move_id.id)]['new_by_name'] + else: + move_id.name = new_values[str(move_id.id)]['new_by_date'] + move_id.with_context(tracking_disable=True).state = new_values[str(move_id.id)]['state'] diff --git a/addons/account/wizard/account_resequence_views.xml b/addons/account/wizard/account_resequence_views.xml new file mode 100644 index 00000000..c1d5cb15 --- /dev/null +++ b/addons/account/wizard/account_resequence_views.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + <record id="account_resequence_view" model="ir.ui.view"> + <field name="name">Re-sequence Journal Entries</field> + <field name="model">account.resequence.wizard</field> + <field name="arch" type="xml"> + <form string="Re-Sequence"> + <field name="move_ids" invisible="1"/> + <field name="new_values" invisible="1"/> + <field name="sequence_number_reset" invisible="1"/> + <group> + <group> + <field name="ordering" widget="radio"/> + </group> + <group> + <field name="first_name"/> + </group> + </group> + <label for="preview_moves" string="Preview Modifications"/> + <field name="preview_moves" widget="account_resequence_widget"/> + <footer> + <button string="Confirm" name="resequence" type="object" default_focus="1" class="btn-primary"/> + <button string="Cancel" class="btn-secondary" special="cancel"/> + </footer> + </form> + </field> + </record> + + <record id="action_account_resequence" model="ir.actions.act_window"> + <field name="name">Resequence</field> + <field name="res_model">account.resequence.wizard</field> + <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('account.group_account_manager'), ref('base.group_system')])]"/> + <field name="binding_model_id" ref="account.model_account_move" /> + <field name="binding_view_types">list</field> + </record> + </data> +</odoo> diff --git a/addons/account/wizard/account_tour_upload_bill.py b/addons/account/wizard/account_tour_upload_bill.py new file mode 100644 index 00000000..26135e1e --- /dev/null +++ b/addons/account/wizard/account_tour_upload_bill.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, api, _ +from odoo.modules.module import get_resource_path +import base64 + +class AccountTourUploadBill(models.TransientModel): + _name = 'account.tour.upload.bill' + _description = 'Account tour upload bill' + _inherits = {'mail.compose.message': 'composer_id'} + + composer_id = fields.Many2one('mail.compose.message', string='Composer', required=True, ondelete='cascade') + + selection = fields.Selection( + selection=lambda self: self._selection_values(), + default="sample" + ) + + sample_bill_preview = fields.Binary( + readonly=True, + compute='_compute_sample_bill_image' + ) + + def _selection_values(self): + journal_alias = self.env['account.journal'] \ + .search([('type', '=', 'purchase'), ('company_id', '=', self.env.company.id)], limit=1) + + return [('sample', _('Try a sample vendor bill')), + ('upload', _('Upload your own bill')), + ('email', _('Or send a bill to %s@%s', journal_alias.alias_name, journal_alias.alias_domain))] + + def _compute_sample_bill_image(self): + """ Retrieve sample bill with facturx to speed up onboarding """ + try: + path = get_resource_path('account_edi_facturx', 'data/files', 'Invoice.pdf') + self.sample_bill_preview = base64.b64encode(open(path, 'rb').read()) if path else False + except (IOError, OSError): + self.sample_bill_preview = False + return + + 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): + 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_invoice_from_attachment(attachment_ids=self.attachment_ids.ids) + elif self.selection == 'sample': + attachment = self.env['ir.attachment'].create({ + 'type': 'binary', + 'name': 'INV/2020/0011.pdf', + 'res_model': 'mail.compose.message', + 'datas': self.sample_bill_preview, + }) + bill_action = purchase_journal.with_context(default_journal_id=purchase_journal.id, default_move_type='in_invoice').create_invoice_from_attachment(attachment.ids) + bill = self.env['account.move'].browse(bill_action['res_id']) + bill.write({ + 'partner_id': self.env.ref('base.main_partner').id, + 'ref': 'INV/2020/0011' + }) + 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) diff --git a/addons/account/wizard/account_tour_upload_bill.xml b/addons/account/wizard/account_tour_upload_bill.xml new file mode 100644 index 00000000..a3ceb8dd --- /dev/null +++ b/addons/account/wizard/account_tour_upload_bill.xml @@ -0,0 +1,49 @@ +<?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 records 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> + <group> + <field name="selection" widget="radio" nolabel="1"/> + </group> + <group attrs="{'invisible': [('selection', '!=', 'sample')]}"> + <field name="sample_bill_preview" widget="pdf_viewer" nolabel="1"/> + </group> + <group 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"/> + <button string="Discard" class="btn-secondary" special="cancel" /> + </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"/> + <button string="Discard" class="btn-secondary" special="cancel" /> + </footer> + </form> + </field> + </record> + </data> +</odoo> diff --git a/addons/account/wizard/account_unreconcile.py b/addons/account/wizard/account_unreconcile.py new file mode 100644 index 00000000..790e9b28 --- /dev/null +++ b/addons/account/wizard/account_unreconcile.py @@ -0,0 +1,12 @@ +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'} diff --git a/addons/account/wizard/account_unreconcile_view.xml b/addons/account/wizard/account_unreconcile_view.xml new file mode 100644 index 00000000..c1175301 --- /dev/null +++ b/addons/account/wizard/account_unreconcile_view.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<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"/> + <button string="Cancel" class="btn-secondary" special="cancel"/> + </footer> + </form> + </field> + </record> + + <record id="action_account_unreconcile" model="ir.actions.act_window"> + <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> + </record> + + </data> +</odoo> diff --git a/addons/account/wizard/account_validate_account_move.py b/addons/account/wizard/account_validate_account_move.py new file mode 100644 index 00000000..3be48a2b --- /dev/null +++ b/addons/account/wizard/account_validate_account_move.py @@ -0,0 +1,23 @@ +from odoo import models, fields, _ +from odoo.exceptions import UserError + + +class ValidateAccountMove(models.TransientModel): + _name = "validate.account.move" + _description = "Validate 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.") + + 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.')) + moves._post(not self.force_post) + return {'type': 'ir.actions.act_window_close'} diff --git a/addons/account/wizard/account_validate_move_view.xml b/addons/account/wizard/account_validate_move_view.xml new file mode 100644 index 00000000..e4494cfc --- /dev/null +++ b/addons/account/wizard/account_validate_move_view.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + + <!--Account Move lines--> + <record id="validate_account_move_view" model="ir.ui.view"> + <field name="name">Post Journal Entries</field> + <field name="model">validate.account.move</field> + <field name="arch" type="xml"> + <form string="Post Journal Entries"> + <group> + <field name="force_post"/> + </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" name="validate_move" type="object" default_focus="1" class="btn-primary"/> + <button string="Cancel" class="btn-secondary" special="cancel"/> + </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'))]"/> + <field name="binding_model_id" ref="account.model_account_move" /> + <field name="binding_view_types">list</field> + </record> + + </data> +</odoo> diff --git a/addons/account/wizard/base_document_layout.py b/addons/account/wizard/base_document_layout.py new file mode 100644 index 00000000..ab76e69a --- /dev/null +++ b/addons/account/wizard/base_document_layout.py @@ -0,0 +1,11 @@ +from odoo import models + + +class BaseDocumentLayout(models.TransientModel): + _inherit = 'base.document.layout' + + def document_layout_save(self): + res = super(BaseDocumentLayout, self).document_layout_save() + for wizard in self: + wizard.company_id.action_save_onboarding_invoice_layout() + return res diff --git a/addons/account/wizard/pos_box.py b/addons/account/wizard/pos_box.py new file mode 100644 index 00000000..7c2931e0 --- /dev/null +++ b/addons/account/wizard/pos_box.py @@ -0,0 +1,54 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + +class CashBox(models.TransientModel): + _register = False + + name = fields.Char(string='Reason', required=True) + # Attention, we don't set a domain, because there is a journal_type key + # in the context of the action + amount = fields.Float(string='Amount', digits=0, required=True) + + def run(self): + context = dict(self._context or {}) + active_model = context.get('active_model', False) + active_ids = context.get('active_ids', []) + + records = self.env[active_model].browse(active_ids) + + return self._run(records) + + def _run(self, records): + for box in self: + for record in records: + if not record.journal_id: + raise UserError(_("Please check that the field 'Journal' is set on the Bank Statement")) + if not record.journal_id.company_id.transfer_account_id: + raise UserError(_("Please check that the field 'Transfer Account' is set on the company.")) + box._create_bank_statement_line(record) + return {} + + def _create_bank_statement_line(self, record): + for box in self: + if record.state == 'confirm': + raise UserError(_("You cannot put/take money in/out for a bank statement which is closed.")) + values = box._calculate_values_for_statement_line(record) + account = record.journal_id.company_id.transfer_account_id + self.env['account.bank.statement.line'].with_context(counterpart_account_id=account.id).sudo().create(values) + + +class CashBoxOut(CashBox): + _name = 'cash.box.out' + _description = 'Cash Box Out' + + def _calculate_values_for_statement_line(self, record): + if not record.journal_id.company_id.transfer_account_id: + raise UserError(_("You have to define an 'Internal Transfer Account' in your cash register's journal.")) + amount = self.amount or 0.0 + return { + 'date': record.date, + 'statement_id': record.id, + 'journal_id': record.journal_id.id, + 'amount': amount, + 'payment_ref': self.name, + } diff --git a/addons/account/wizard/pos_box.xml b/addons/account/wizard/pos_box.xml new file mode 100644 index 00000000..975b7fd5 --- /dev/null +++ b/addons/account/wizard/pos_box.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<odoo> + <data> + <record model="ir.ui.view" id="cash_box_out_form"> + <field name="name">cash_box_out</field> + <field name="model">cash.box.out</field> + <field name="arch" type="xml"> + <form string="Take Money In/Out"> + <separator string="Describe why you put/take money from the cash register:"/> + <group> + <field name="name" class="oe_inline"/> + <field name="amount" class="oe_inline"/> + </group> + + <footer> + <button name="run" string="Take Money In/Out" type="object" class="btn-primary"/> + <button class="btn-secondary" special="cancel" string="Cancel" /> + </footer> + </form> + </field> + </record> + + <record id="action_cash_box_out" model="ir.actions.act_window"> + <field name="name">Take Money In/Out</field> + <field name="res_model">cash.box.out</field> + <field name="view_mode">form</field> + <field name="target">new</field> + </record> + </data> +</odoo> diff --git a/addons/account/wizard/setup_wizards.py b/addons/account/wizard/setup_wizards.py new file mode 100644 index 00000000..02d772bd --- /dev/null +++ b/addons/account/wizard/setup_wizards.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import date, timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class FinancialYearOpeningWizard(models.TransientModel): + _name = 'account.financial.year.op' + _description = 'Opening Balance of Financial Year' + + company_id = fields.Many2one(comodel_name='res.company', required=True) + opening_move_posted = fields.Boolean(string='Opening Move Posted', compute='_compute_opening_move_posted') + opening_date = fields.Date(string='Opening Date', required=True, related='company_id.account_opening_date', help="Date from which the accounting is managed in Odoo. It is the date of the opening entry.", readonly=False) + fiscalyear_last_day = fields.Integer(related="company_id.fiscalyear_last_day", required=True, readonly=False, + help="The last day of the month will be used if the chosen day doesn't exist.") + fiscalyear_last_month = fields.Selection(related="company_id.fiscalyear_last_month", readonly=False, + required=True, + help="The last day of the month will be used if the chosen day doesn't exist.") + + @api.depends('company_id.account_opening_move_id') + def _compute_opening_move_posted(self): + for record in self: + record.opening_move_posted = record.company_id.opening_move_posted() + + @api.constrains('fiscalyear_last_day', 'fiscalyear_last_month') + def _check_fiscalyear(self): + # We try if the date exists in 2020, which is a leap year. + # We do not define the constrain on res.company, since the recomputation of the related + # fields is done one field at a time. + for wiz in self: + try: + date(2020, int(wiz.fiscalyear_last_month), wiz.fiscalyear_last_day) + except ValueError: + raise ValidationError( + _('Incorrect fiscal year date: day is out of range for month. Month: %s; Day: %s') % + (wiz.fiscalyear_last_month, wiz.fiscalyear_last_day) + ) + + def write(self, 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), + }) + + vals.pop('opening_date', None) + vals.pop('fiscalyear_last_day', None) + vals.pop('fiscalyear_last_month', 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') + + +class SetupBarBankConfigWizard(models.TransientModel): + _inherits = {'res.partner.bank': 'res_partner_bank_id'} + _name = 'account.setup.bank.manual.config' + _description = 'Bank setup manual config' + _check_company_auto = True + + res_partner_bank_id = fields.Many2one(comodel_name='res.partner.bank', ondelete='cascade', required=True) + new_journal_name = fields.Char(default=lambda self: self.linked_journal_id.name, inverse='set_linked_journal_id', required=True, help='Will be used to name the Journal related to this bank account') + linked_journal_id = fields.Many2one(string="Journal", + comodel_name='account.journal', inverse='set_linked_journal_id', + compute="_compute_linked_journal_id", check_company=True, + domain="[('type','=','bank'), ('bank_account_id', '=', False), ('company_id', '=', company_id)]") + 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()) + + 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) + + @api.onchange('acc_number') + def _onchange_acc_number(self): + for record in self: + record.new_journal_name = record.acc_number + + @api.model + def create(self, vals): + """ This wizard is only used to setup an account for the current active + company, so we always inject the corresponding partner when creating + the model. + """ + vals['partner_id'] = self.env.company.partner_id.id + 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']: + 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 + + return super(SetupBarBankConfigWizard, self).create(vals) + + @api.onchange('linked_journal_id') + def _onchange_new_journal_related_data(self): + for record in self: + if record.linked_journal_id: + record.new_journal_name = record.linked_journal_id.name + + @api.depends('journal_id') # Despite its name, journal_id is actually a One2many field + def _compute_linked_journal_id(self): + for record in self: + record.linked_journal_id = record.journal_id and record.journal_id[0] or record.default_linked_journal_id() + + def default_linked_journal_id(self): + default = self.env['account.journal'].search([('type', '=', 'bank'), ('bank_account_id', '=', False)], limit=1) + return default[:1].id + + def set_linked_journal_id(self): + """ Called when saving the wizard. + """ + 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) + company = self.env.company + selected_journal = self.env['account.journal'].create({ + 'name': record.new_journal_name, + 'code': new_journal_code, + 'type': 'bank', + 'company_id': company.id, + 'bank_account_id': record.res_partner_bank_id.id, + }) + 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 + extension hook in account_bank_statement_import. + """ + self.linked_journal_id.mark_bank_setup_as_done_action() diff --git a/addons/account/wizard/setup_wizards_view.xml b/addons/account/wizard/setup_wizards_view.xml new file mode 100644 index 00000000..3ae4ae67 --- /dev/null +++ b/addons/account/wizard/setup_wizards_view.xml @@ -0,0 +1,90 @@ +<?xml version="1.0" encoding="UTF-8"?> + +<odoo> + <data> + <record id="setup_financial_year_opening_form" model="ir.ui.view"> + <field name="name">account.financial.year.op.setup.wizard.form</field> + <field name="model">account.financial.year.op</field> + <field name="arch" type="xml"> + <form> + <sheet> + <group> + <group string="Fiscal Years"> + <field name="opening_move_posted" invisible="1"/> + <field name="opening_date" attrs="{'readonly': [('opening_move_posted', '=', True)]}"/> + + <label for="fiscalyear_last_month" string="Fiscal Year End"/> + <div> + <field name="fiscalyear_last_day" class="oe_inline text-center" style="width: 20% !important;"/> + <span style="width: 5%; display: inline-block"/> + <field name="fiscalyear_last_month" class="oe_inline" style="width: 75% !important;"/> + </div> + </group> + </group> + </sheet> + <footer> + <button name="action_save_onboarding_fiscal_year" string="Apply" + class="oe_highlight" type="object" /> + <button special="cancel" string="Cancel" /> + </footer> + </form> + </field> + </record> + + <record id="setup_bank_account_wizard" model="ir.ui.view"> + <field name="name">account.online.sync.res.partner.bank.setup.form</field> + <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">Leave empty to create a new journal for this bank account, or select a journal to link it with the bank account.</span> + </group> + </group> + </sheet> + <footer> + <button string="Create" class="oe_highlight" type="object" name="validate"/> + <button string="Cancel" special="cancel"/> + </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="model">account.account</field> + <field name="arch" type="xml"> + <tree editable="top" create="1" delete="1" multi_edit="1" decoration-muted="opening_debit == 0 and opening_credit == 0"> + <field name="code"/> + <field name="name"/> + <field name="company_id" invisible="1"/> + <field name="user_type_id" widget="account_hierarchy_selection"/> + <field name="reconcile" widget="boolean_toggle"/> + <field name="opening_debit"/> + <field name="opening_credit"/> + <field name="opening_balance" optional="hide"/> + <field name="tax_ids" optional="hide" widget="many2many_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-right btn-secondary"/> + </tree> + </field> + </record> + + </data> +</odoo> diff --git a/addons/account/wizard/wizard_tax_adjustments.py b/addons/account/wizard/wizard_tax_adjustments.py new file mode 100644 index 00000000..6214f572 --- /dev/null +++ b/addons/account/wizard/wizard_tax_adjustments.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields, api + + +class TaxAdjustments(models.TransientModel): + _name = 'tax.adjustments.wizard' + _description = 'Tax Adjustments Wizard' + + def _get_default_journal(self): + return self.env['account.journal'].search([('type', '=', 'general')], limit=1).id + + reason = fields.Char(string='Justification', required=True) + journal_id = fields.Many2one('account.journal', string='Journal', required=True, default=_get_default_journal, domain=[('type', '=', 'general')]) + date = fields.Date(required=True, default=fields.Date.context_today) + debit_account_id = fields.Many2one('account.account', string='Debit account', required=True, + domain="[('deprecated', '=', False), ('is_off_balance', '=', False)]") + credit_account_id = fields.Many2one('account.account', string='Credit account', required=True, + domain="[('deprecated', '=', False), ('is_off_balance', '=', False)]") + amount = fields.Monetary(currency_field='company_currency_id', required=True) + adjustment_type = fields.Selection([('debit', 'Applied on debit journal item'), ('credit', 'Applied on credit journal item')], string="Adjustment Type", required=True) + tax_report_line_id = fields.Many2one(string="Report Line", comodel_name='account.tax.report.line', required=True, help="The report line to make an adjustment for.") + company_currency_id = fields.Many2one('res.currency', readonly=True, default=lambda x: x.env.company.currency_id) + report_id = fields.Many2one(string="Report", related='tax_report_line_id.report_id') + + + def create_move(self): + move_line_vals = [] + + is_debit = self.adjustment_type == 'debit' + sign_multiplier = (self.amount<0 and -1 or 1) * (self.adjustment_type == 'credit' and -1 or 1) + filter_lambda = (sign_multiplier < 0) and (lambda x: x.tax_negate) or (lambda x: not x.tax_negate) + adjustment_tag = self.tax_report_line_id.tag_ids.filtered(filter_lambda) + + # Vals for the amls corresponding to the ajustment tag + move_line_vals.append((0, 0, { + 'name': self.reason, + 'debit': is_debit and abs(self.amount) or 0, + 'credit': not is_debit and abs(self.amount) or 0, + 'account_id': is_debit and self.debit_account_id.id or self.credit_account_id.id, + 'tax_tag_ids': [(6, False, [adjustment_tag.id])], + })) + + # Vals for the counterpart line + move_line_vals.append((0, 0, { + 'name': self.reason, + 'debit': not is_debit and abs(self.amount) or 0, + 'credit': is_debit and abs(self.amount) or 0, + 'account_id': is_debit and self.credit_account_id.id or self.debit_account_id.id, + })) + + # Create the move + vals = { + 'journal_id': self.journal_id.id, + 'date': self.date, + 'state': 'draft', + 'line_ids': move_line_vals, + } + move = self.env['account.move'].create(vals) + move._post() + + # Return an action opening the created move + result = self.env['ir.actions.act_window']._for_xml_id('account.action_move_line_form') + result['views'] = [(False, 'form')] + result['res_id'] = move.id + return result diff --git a/addons/account/wizard/wizard_tax_adjustments_view.xml b/addons/account/wizard/wizard_tax_adjustments_view.xml new file mode 100644 index 00000000..39e98e27 --- /dev/null +++ b/addons/account/wizard/wizard_tax_adjustments_view.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + + <record id="tax_adjustments_wizard" model="ir.ui.view"> + <field name="name">tax.adjustments.wizard.form</field> + <field name="model">tax.adjustments.wizard</field> + <field name="arch" type="xml"> + <form> + <h1> + <field name="reason" class="oe_inline" placeholder="Reason..."/> + </h1> + <group> + <field name="report_id" invisible="1"/> + <field name="tax_report_line_id" widget="selection" domain="[('tag_name', '!=', None)]"/> + </group> + <group> + <group> + <field name="amount"/> + </group> + <group> + <field name="adjustment_type"/> + </group> + <group string="Accounts"> + <field name="debit_account_id"/> + <field name="credit_account_id"/> + </group> + <group string="Options"> + <field name="journal_id"/> + <field name="date"/> + </group> + </group> + <footer> + <button name="create_move" string="Create and post move" type="object" default_focus="1" class="oe_highlight"/> + <button string="Cancel" class="btn btn-secondary" special="cancel" /> + </footer> + </form> + </field> + </record> +</odoo> |
