forked from Mapan/odoo17e
384 lines
18 KiB
Python
384 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
import base64
|
|
|
|
from odoo import models, fields, api, _
|
|
from odoo.exceptions import ValidationError, UserError
|
|
|
|
|
|
class AccountBatchPayment(models.Model):
|
|
_name = "account.batch.payment"
|
|
_description = "Batch Payment"
|
|
_order = "date desc, id desc"
|
|
_inherit = ["mail.thread", "mail.activity.mixin"]
|
|
|
|
name = fields.Char(required=True, copy=False, string='Reference')
|
|
date = fields.Date(required=True, copy=False, default=fields.Date.context_today, tracking=True)
|
|
state = fields.Selection([
|
|
('draft', 'New'),
|
|
('sent', 'Sent'),
|
|
('reconciled', 'Reconciled'),
|
|
], store=True, compute='_compute_state', default='draft', tracking=True)
|
|
journal_id = fields.Many2one(
|
|
'account.journal',
|
|
string='Bank',
|
|
check_company=True,
|
|
domain=[('type', '=', 'bank')],
|
|
tracking=True,
|
|
)
|
|
payment_ids = fields.One2many('account.payment', 'batch_payment_id', string="Payments", required=True)
|
|
currency_id = fields.Many2one('res.currency', compute='_compute_currency', store=True, readonly=True)
|
|
company_currency_id = fields.Many2one(
|
|
string="Company Currency",
|
|
related='journal_id.company_id.currency_id',
|
|
store=True,
|
|
)
|
|
amount_residual = fields.Monetary(
|
|
currency_field='company_currency_id',
|
|
compute='_compute_from_payment_ids',
|
|
store=True,
|
|
)
|
|
amount_residual_currency = fields.Monetary(
|
|
currency_field='currency_id',
|
|
compute='_compute_from_payment_ids',
|
|
store=True,
|
|
)
|
|
amount = fields.Monetary(
|
|
currency_field='currency_id',
|
|
compute='_compute_from_payment_ids',
|
|
store=True,
|
|
)
|
|
batch_type = fields.Selection(selection=[('inbound', 'Inbound'), ('outbound', 'Outbound')], required=True, default='inbound', tracking=True)
|
|
payment_method_id = fields.Many2one(
|
|
comodel_name='account.payment.method',
|
|
string='Payment Method', store=True, readonly=False,
|
|
compute='_compute_payment_method_id',
|
|
domain="[('id', 'in', available_payment_method_ids)]",
|
|
help="The payment method used by the payments in this batch.", tracking=True)
|
|
available_payment_method_ids = fields.Many2many(
|
|
comodel_name='account.payment.method',
|
|
compute='_compute_available_payment_method_ids')
|
|
payment_method_code = fields.Char(related='payment_method_id.code', tracking=True)
|
|
export_file_create_date = fields.Date(string='Generation Date', default=fields.Date.today, readonly=True, help="Creation date of the related export file.", copy=False)
|
|
export_file = fields.Binary(string='File', readonly=True, help="Export file related to this batch", copy=False)
|
|
export_filename = fields.Char(string='File Name', help="Name of the export file generated for this batch", store=True, copy=False)
|
|
|
|
file_generation_enabled = fields.Boolean(help="Whether or not this batch payment should display the 'Generate File' button instead of 'Print' in form view.", compute='_compute_file_generation_enabled')
|
|
|
|
@api.depends('batch_type', 'journal_id', 'payment_ids')
|
|
def _compute_payment_method_id(self):
|
|
''' Compute the 'payment_method_id' field.
|
|
This field is not computed in '_compute_available_payment_method_ids' because it's a stored editable one.
|
|
'''
|
|
for batch in self:
|
|
if batch.payment_ids:
|
|
batch.payment_method_id = batch.payment_ids.payment_method_line_id[0].payment_method_id
|
|
continue
|
|
|
|
if not batch.journal_id:
|
|
batch.available_payment_method_ids = False
|
|
batch.payment_method_id = False
|
|
continue
|
|
|
|
available_payment_method_lines = batch.journal_id._get_available_payment_method_lines(batch.batch_type)
|
|
|
|
batch.available_payment_method_ids = available_payment_method_lines.mapped('payment_method_id')
|
|
|
|
# Select the first available one by default.
|
|
if batch.available_payment_method_ids:
|
|
batch.payment_method_id = batch.available_payment_method_ids[0]._origin
|
|
else:
|
|
batch.payment_method_id = False
|
|
|
|
@api.depends('batch_type', 'journal_id')
|
|
def _compute_available_payment_method_ids(self):
|
|
for batch in self:
|
|
available_payment_method_lines = batch.journal_id._get_available_payment_method_lines(batch.batch_type)
|
|
batch.available_payment_method_ids = available_payment_method_lines.mapped('payment_method_id')
|
|
|
|
@api.depends('payment_ids.move_id.is_move_sent', 'payment_ids.is_matched')
|
|
def _compute_state(self):
|
|
for batch in self:
|
|
if batch.payment_ids and all(pay.is_matched and pay.is_move_sent for pay in batch.payment_ids):
|
|
batch.state = 'reconciled'
|
|
elif batch.payment_ids and all(pay.is_move_sent for pay in batch.payment_ids):
|
|
batch.state = 'sent'
|
|
else:
|
|
batch.state = 'draft'
|
|
|
|
@api.depends('payment_method_id')
|
|
def _compute_file_generation_enabled(self):
|
|
for record in self:
|
|
record.file_generation_enabled = record.payment_method_id.code in record._get_methods_generating_files()
|
|
|
|
def _get_methods_generating_files(self):
|
|
""" Hook for extension. Any payment method whose code stands in the list
|
|
returned by this function will see the "print" button disappear on batch
|
|
payments form when it gets selected and an 'Export file' appear instead.
|
|
"""
|
|
return []
|
|
|
|
@api.depends('journal_id')
|
|
def _compute_currency(self):
|
|
for batch in self:
|
|
batch.currency_id = batch.journal_id.currency_id or batch.company_currency_id or self.env.company.currency_id
|
|
|
|
@api.depends('currency_id', 'payment_ids.amount', 'payment_ids.is_matched')
|
|
def _compute_from_payment_ids(self):
|
|
for batch in self:
|
|
amount = 0.0
|
|
amount_residual = 0.0
|
|
amount_residual_currency = 0.0
|
|
for payment in batch.payment_ids:
|
|
liquidity_lines, _counterpart_lines, _writeoff_lines = payment._seek_for_lines()
|
|
for line in liquidity_lines:
|
|
amount += line.currency_id._convert(
|
|
from_amount=line.amount_currency,
|
|
to_currency=batch.currency_id,
|
|
company=line.company_id,
|
|
date=line.date,
|
|
)
|
|
amount_residual += line.amount_residual
|
|
amount_residual_currency += line.amount_residual_currency
|
|
|
|
batch.amount_residual = amount_residual
|
|
batch.amount = amount
|
|
batch.amount_residual_currency = amount_residual_currency
|
|
|
|
@api.constrains('batch_type', 'journal_id', 'payment_ids')
|
|
def _check_payments_constrains(self):
|
|
for record in self:
|
|
all_companies = set(record.payment_ids.mapped('company_id'))
|
|
if len(all_companies) > 1:
|
|
raise ValidationError(_("All payments in the batch must belong to the same company."))
|
|
all_journals = set(record.payment_ids.mapped('journal_id'))
|
|
if len(all_journals) > 1 or (record.payment_ids and record.payment_ids[:1].journal_id != record.journal_id):
|
|
raise ValidationError(_("The journal of the batch payment and of the payments it contains must be the same."))
|
|
all_types = set(record.payment_ids.mapped('payment_type'))
|
|
if all_types and record.batch_type not in all_types:
|
|
raise ValidationError(_("The batch must have the same type as the payments it contains."))
|
|
all_payment_methods = record.payment_ids.payment_method_id
|
|
if len(all_payment_methods) > 1:
|
|
raise ValidationError(_("All payments in the batch must share the same payment method."))
|
|
if all_payment_methods and record.payment_method_id not in all_payment_methods:
|
|
raise ValidationError(_("The batch must have the same payment method as the payments it contains."))
|
|
payment_null = record.payment_ids.filtered(lambda p: p.amount == 0)
|
|
if payment_null:
|
|
raise ValidationError(_('You cannot add payments with zero amount in a Batch Payment.'))
|
|
non_posted = record.payment_ids.filtered(lambda p: p.state != 'posted')
|
|
if non_posted:
|
|
raise ValidationError(_('You cannot add payments that are not posted.'))
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
today = fields.Date.context_today(self)
|
|
all_payment_ids = []
|
|
for vals in vals_list:
|
|
vals['name'] = self._get_batch_name(
|
|
vals.get('batch_type'),
|
|
vals.get('date', today),
|
|
vals)
|
|
if 'payment_ids' in vals:
|
|
payments = self.new({'payment_ids': vals['payment_ids']}).payment_ids
|
|
if payments._origin.batch_payment_id:
|
|
raise ValidationError(_('You cannot create a batch with payments that are already in another batch.'))
|
|
# Collect all payment IDs
|
|
all_payment_ids.extend(payments.ids)
|
|
|
|
if len(all_payment_ids) != len(set(all_payment_ids)):
|
|
raise ValidationError(_('You cannot create batches with overlapping payments.'))
|
|
return super().create(vals_list)
|
|
|
|
def write(self, vals):
|
|
if 'batch_type' in vals:
|
|
vals['name'] = self.with_context(default_journal_id=self.journal_id.id)._get_batch_name(vals['batch_type'], self.date, vals)
|
|
if 'payment_ids' in vals:
|
|
if len(self) > 1:
|
|
raise ValidationError(_('You cannot add the same payment to multiple batches.'))
|
|
original_payments = self.new({'payment_ids': vals['payment_ids']}).payment_ids._origin
|
|
if original_payments.batch_payment_id - self:
|
|
raise ValidationError(_('You cannot create a batch with payments that are already in another batch.'))
|
|
|
|
rslt = super(AccountBatchPayment, self).write(vals)
|
|
|
|
return rslt
|
|
|
|
@api.model
|
|
def _get_batch_name(self, batch_type, sequence_date, vals):
|
|
if not vals.get('name'):
|
|
sequence_code = 'account.inbound.batch.payment'
|
|
if batch_type == 'outbound':
|
|
sequence_code = 'account.outbound.batch.payment'
|
|
return self.env['ir.sequence'].with_context(sequence_date=sequence_date).next_by_code(sequence_code)
|
|
return vals['name']
|
|
|
|
@api.depends('state')
|
|
def _compute_display_name(self):
|
|
state_values = dict(self._fields['state'].selection)
|
|
for batch in self:
|
|
batch.display_name = f'{batch.name} ({state_values.get(batch.state)})'
|
|
|
|
def validate_batch(self):
|
|
""" Verifies the content of a batch and proceeds to its sending if possible.
|
|
If not, opens a wizard listing the errors and/or warnings encountered.
|
|
"""
|
|
self.ensure_one()
|
|
if not self.payment_ids:
|
|
raise UserError(_("Cannot validate an empty batch. Please add some payments to it first."))
|
|
|
|
errors = not self.export_file and self.check_payments_for_errors() or [] # We don't re-check for errors if we are regenerating the file (we know there aren't any)
|
|
warnings = self.check_payments_for_warnings()
|
|
if errors or warnings:
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'view_mode': 'form',
|
|
'res_model': 'account.batch.error.wizard',
|
|
'target': 'new',
|
|
'res_id': self.env['account.batch.error.wizard'].create_from_errors_list(self, errors, warnings).id,
|
|
}
|
|
|
|
return self._send_after_validation()
|
|
|
|
def validate_batch_button(self):
|
|
return self.validate_batch()
|
|
|
|
def _send_after_validation(self):
|
|
""" Sends the payments of a batch (possibly generating an export file)
|
|
once the batch has been validated.
|
|
"""
|
|
|
|
self.ensure_one()
|
|
if self.payment_ids:
|
|
self.payment_ids.mark_as_sent()
|
|
|
|
if self.file_generation_enabled:
|
|
return self.export_batch_payment()
|
|
|
|
def check_payments_for_warnings(self):
|
|
""" Checks the payments of this batch and returns (if relevant) some
|
|
warnings about them. These warnings are not to be confused with errors,
|
|
they are only messgaes displayed to make sure the user is aware of some
|
|
specificities in the payments he's put in the batch. He will be able to
|
|
ignore them.
|
|
|
|
:return: A list of dictionaries, each one corresponding to a distinct
|
|
warning and containing the following keys:
|
|
- 'title': A short name for the warning (mandatory)
|
|
- 'records': The recordset of payments concerned by this warning (mandatory)
|
|
- 'help': A help text to give the user further information
|
|
on the reason this warning exists (optional)
|
|
"""
|
|
return []
|
|
|
|
def check_payments_for_errors(self):
|
|
""" Goes through all the payments of the batches contained in this
|
|
record set, and returns the ones that would impeach batch validation,
|
|
in such a way that the payments impeaching validation for the same reason
|
|
are grouped under a common error message. This function is a hook for
|
|
extension for modules making a specific use of batch payments, such as SEPA
|
|
ones.
|
|
|
|
:return: A list of dictionaries, each one corresponding to a distinct
|
|
error and containing the following keys:
|
|
- 'title': A short name for the error (mandatory)
|
|
- 'records': The recordset of payments facing this error (mandatory)
|
|
- 'help': A help text to give the user further information
|
|
on how to solve the error (optional)
|
|
"""
|
|
self.ensure_one()
|
|
#We first try to post all the draft batch payments
|
|
rslt = self._check_and_post_draft_payments(self.payment_ids.filtered(lambda x: x.state == 'draft'))
|
|
|
|
wrong_state_payments = self.payment_ids.filtered(lambda x: x.state != 'posted')
|
|
|
|
if wrong_state_payments:
|
|
rslt.append({
|
|
'title': _("Payments must be posted to be added to a batch."),
|
|
'records': wrong_state_payments,
|
|
'help': _("Set payments state to \"posted\".")
|
|
})
|
|
|
|
if self.batch_type == 'outbound':
|
|
not_allowed_payments = self.payment_ids.filtered(lambda x: x.partner_bank_id and not x.partner_bank_id.allow_out_payment)
|
|
if not_allowed_payments:
|
|
rslt.append({
|
|
'code': 'out_payment_not_allowed',
|
|
'title': _("Some recipient accounts do not allow out payments."),
|
|
'records': not_allowed_payments,
|
|
'help': _("Target another recipient account or allow sending money to the current one.")
|
|
})
|
|
|
|
sent_payments = self.payment_ids.filtered(lambda x: x.is_move_sent)
|
|
if sent_payments:
|
|
rslt.append({
|
|
'title': _("Some payments have already been sent."),
|
|
'records': sent_payments,
|
|
})
|
|
|
|
if self.batch_type == 'inbound':
|
|
pmls = self.journal_id.inbound_payment_method_line_ids
|
|
default_payment_account = self.journal_id.company_id.account_journal_payment_debit_account_id
|
|
else:
|
|
pmls = self.journal_id.outbound_payment_method_line_ids
|
|
default_payment_account = self.journal_id.company_id.account_journal_payment_credit_account_id
|
|
pmls = pmls.filtered(lambda x: x.payment_method_id == self.payment_method_id)
|
|
no_statement_reconciliation = self.journal_id.default_account_id == (pmls.payment_account_id[:1] or default_payment_account)
|
|
bank_reconciled_payments = self.payment_ids.filtered(lambda x: x.is_matched)
|
|
if bank_reconciled_payments and not no_statement_reconciliation:
|
|
rslt.append({
|
|
'title': _("Some payments have already been matched with a bank statement."),
|
|
'records': bank_reconciled_payments,
|
|
})
|
|
|
|
return rslt
|
|
|
|
def _check_and_post_draft_payments(self, draft_payments):
|
|
""" Tries posting each of the draft payments contained in this batch.
|
|
If it fails and raise a UserError, it is catched and the process continues
|
|
on the following payments. All the encountered errors are then returned
|
|
withing a dictionary, in the same fashion as check_payments_for_errors.
|
|
"""
|
|
exceptions_mapping = {}
|
|
for payment in draft_payments:
|
|
try:
|
|
payment.action_post()
|
|
except UserError as e:
|
|
name = e.args[0]
|
|
if name in exceptions_mapping:
|
|
exceptions_mapping[name] += payment
|
|
else:
|
|
exceptions_mapping[name] = payment
|
|
|
|
return [{'title': error, 'records': pmts} for error, pmts in exceptions_mapping.items()]
|
|
|
|
def export_batch_payment(self):
|
|
#export and save the file for each batch payment
|
|
self.check_access_rights('write')
|
|
self.check_access_rule('write')
|
|
for record in self.sudo():
|
|
record = record.with_company(record.journal_id.company_id)
|
|
export_file_data = record._generate_export_file()
|
|
record.export_file = export_file_data['file']
|
|
record.export_filename = export_file_data['filename']
|
|
record.export_file_create_date = fields.Date.today()
|
|
record.message_post(
|
|
attachments=[
|
|
(record.export_filename, base64.decodebytes(record.export_file)),
|
|
]
|
|
)
|
|
|
|
def print_batch_payment(self):
|
|
return self.env.ref('account_batch_payment.action_print_batch_payment').report_action(self, config=False)
|
|
|
|
def _generate_export_file(self):
|
|
""" To be overridden by modules adding support for different export format.
|
|
This function returns False if no export file could be generated
|
|
for this batch. Otherwise, it returns a dictionary containing the following keys:
|
|
- file: the content of the generated export file, in base 64.
|
|
- filename: the name of the generated file
|
|
- warning: (optional) the warning message to display
|
|
|
|
"""
|
|
self.ensure_one()
|
|
return False
|