Files
odoo_source/addons/account/models/account_payment_term.py
T
william-andre d8d47f9ff8 [REF] accounting v16. Yeeeeaah
TLDR:
* invoices are implemented using computed methods instead of onchange
* the synchronization only happens when switching tabs in the Form view
  to improve perfs.

_______________________________________________________________________

The whole engine of the synchronization of Invoices to the Journal
Entries has been refactored
* by using computed fields instead of onchange functions
* by synchronizing only from invoice to journal entry in `create` and
  `write`
* by saving when switching tabs on the Invoice form, to synchronize
  before showing the values

This comes with numerous advantages:
* no need to call the onchange methods manually
* no need to use the Form emulator to build invoices (i.e. EDI, OCR,
  intercompany, ...)
* the performance for invoices with many lines improves drastically, going
  from 2 minutes to 4 seconds to create an invoice with 500 lines
* the model is more declarative, we can now see how the values are computed
  instead of having the values being copied from various places.
* remove the hack in `onchange` that disabled the recursivity of it,
  which was unexpected and needed to be managed manually in all the
  onchange methods

This means that:
* Some fields need to be exclusively computed on journal entries values
  or invoice values, more specifically the Tax Summary widget.
  It is now
    - computed from entry lines, when opening the view
    - computed from invoice lines when changing those, because the tax lines
      will need to be recomputed anyways, erasing previously set values
    - set with an inverse function when saving; after the sync has been done
* Some possible operations previously possible have been dropped.
  (i.e. look at the removed test test_in_invoice_line_onchange_accounting_fields_1)
  This is because such a behavior was undefined (how is changing the balance going
  to affect the unit price? How is the amount currency going to affect it?)

_______________________________________________________________________

Implementation Details
----------------------

The "dynamic lines", meaning the payment terms and the tax lines are now
only created in the `create` and `write` functions.
In order to reduce code duplication, it has been implemented using
context managers used in both `account.move` and `account.move.line`
These context managers help comparing the values before/after, acting
like a local `onchange`, but getting benefit from the dirty flags from
the `compute` dependences.
This is relying on computed fields on the move (`needed_terms`) and on
the lines (`compute_all_tax`) which contain the values needed for the
related move.
Depending on the needed values and the existing values (`term_key` and
`tax_key`, respectively) the context manager will determine what needs
to be created/updated/deleted.

Some related changes are to produce a `dict` instead of a `str` for the
`tax_totals` (previously `tax_totals_json`) fields, by simplicity to
reduce the complexity of IO, and simplicity of debugging, because the
logic of the field needed to change (cannot be computed at the same time
anymore since it needed the lines to be synced)

By simplicity, and also because it makes more sense, some boolean fields
have been merged into `display_type`:
* `is_rounding_line`
* `exclude_from_invoice_tab`
* `is_anglo_saxon_line`

The `price_unit`, `quantity` and other "invoice fields" are now not set
anymore on lines that are not product lines since it didn't make any
sense to have it.

Performances
------------

You have to keep in mind that a simple `create` didn't compute a lot of
fields, for instance not taxes were set, no payment terms,...
Now it does.

```python
import random
from timeit import timeit
from odoo import Command
domain = [('company_id', 'in', (False, self.env.company.id))]
products = self.env['product.product'].search(domain).ids
partners = self.env['res.partner'].search(domain).ids
taxes = self.env['account.tax'].search(domain).ids
def create(nmove, nline):
    self.env['account.move'].create([
        {
            'move_type': 'out_invoice',
            'partner_id': random.choice(partners),
            'invoice_line_ids': [
                Command.create({
                    'name': f'line{i}',
                    'product_id': random.choice(products),
                    'tax_ids': [Command.set([random.choice(taxes)])],
                })
                for i in range(nline)
            ]
        }
        for j in range(nmove)
    ])
                                                             # After  | Before
print(timeit("create(1, 1)", globals=globals(), number=1))   # 0.11   | 0.09
print(timeit("create(100, 1)", globals=globals(), number=1)) # 2.76   | 2.50
print(timeit("create(500, 1)", globals=globals(), number=1)) # 14.56  | 12.34
print(timeit("create(1, 100)", globals=globals(), number=1)) # 1.03   | 5.52
print(timeit("create(1, 500)", globals=globals(), number=1)) # 3.99   | 125.02
print(timeit("create(50, 50)", globals=globals(), number=1)) # 19.44  | 79.55
```

Another metric that can be used is running the test suite with
`--test-tags=/account` (only `account` installed)
* before: 404s, 267127 queries (366 tests)
* after: 318s, 232125 queries (362 tests)

Why this commit title?
----------------------

Someone told me that this was the perfect way of naming your commits.
c04065abd8

task-2711317

closes odoo/odoo#96134

Related: odoo/upgrade#3715
Related: odoo/enterprise#29758
Signed-off-by: Laurent Smet <las@odoo.com>
2022-08-03 13:44:49 +02:00

142 lines
7.7 KiB
Python

# -*- coding: utf-8 -*-
from odoo import api, exceptions, fields, models, _
from odoo.exceptions import UserError, ValidationError
from dateutil.relativedelta import relativedelta
class AccountPaymentTerm(models.Model):
_name = "account.payment.term"
_description = "Payment Terms"
_order = "sequence, id"
def _default_line_ids(self):
return [(0, 0, {'value': 'balance', 'value_amount': 0.0, 'sequence': 9, 'days': 0, 'option': 'day_after_invoice_date'})]
name = fields.Char(string='Payment Terms', translate=True, required=True)
active = fields.Boolean(default=True, help="If the active field is set to False, it will allow you to hide the payment terms without removing it.")
note = fields.Html(string='Description on the Invoice', translate=True)
line_ids = fields.One2many('account.payment.term.line', 'payment_id', string='Terms', copy=True, default=_default_line_ids)
company_id = fields.Many2one('res.company', string='Company')
sequence = fields.Integer(required=True, default=10)
@api.constrains('line_ids')
def _check_lines(self):
for terms in self:
payment_term_lines = terms.line_ids.sorted()
if payment_term_lines and payment_term_lines[-1].value != 'balance':
raise ValidationError(_('The last line of a Payment Term should have the Balance type.'))
lines = terms.line_ids.filtered(lambda r: r.value == 'balance')
if len(lines) > 1:
raise ValidationError(_('A Payment Term should have only one line of type Balance.'))
def compute(self, company_value, foreign_value, date_ref, currency):
"""Get the distribution of this payment term.
:param company_value (float): the amount to pay in the company's currency
:param foreign_value (float): the amount to pay in the document's currency
:param date_ref (datetime.date): the reference date
:param currency (Model<res.currency>): the document's currency
:return (list<tuple<datetime.date,tuple<float,float>>>): the amount in the company's currency and
the document's currency, respectively for each required payment date
"""
self.ensure_one()
date_ref = date_ref or fields.Date.context_today(self)
company_amount = company_value
foreign_amount = foreign_value
sign = company_value < 0 and -1 or 1
result = []
company_currency = self.env.company.currency_id
for line in self.line_ids:
if line.value == 'fixed':
company_amt = sign * company_currency.round(line.value_amount)
foreign_amt = sign * currency.round(line.value_amount)
elif line.value == 'percent':
company_amt = company_currency.round(company_value * (line.value_amount / 100.0))
foreign_amt = currency.round(foreign_value * (line.value_amount / 100.0))
elif line.value == 'balance':
company_amt = company_currency.round(company_amount)
foreign_amt = currency.round(foreign_amount)
next_date = fields.Date.from_string(date_ref)
if line.option == 'day_after_invoice_date':
next_date += relativedelta(days=line.days)
if line.day_of_the_month > 0:
months_delta = (line.day_of_the_month < next_date.day) and 1 or 0
next_date += relativedelta(day=line.day_of_the_month, months=months_delta)
elif line.option == 'after_invoice_month':
next_first_date = next_date + relativedelta(day=1, months=1) # Getting 1st of next month
next_date = next_first_date + relativedelta(days=line.days - 1)
elif line.option == 'day_following_month':
next_date += relativedelta(day=line.days, months=1)
elif line.option == 'day_current_month':
next_date += relativedelta(day=line.days, months=0)
result.append((fields.Date.to_date(next_date), (company_amt, foreign_amt)))
company_amount -= company_amt
foreign_amt -= foreign_amount
company_amount = sum(company_amt for _, (company_amt, _) in result)
company_dist = company_currency.round(company_value - company_amount)
foreign_amount = sum(foreign_amt for _, (_, foreign_amt) in result)
foreign_dist = currency.round(foreign_value - foreign_amount)
if company_dist or foreign_dist:
last_date = result and result[-1][0] or fields.Date.context_today(self)
result.append((last_date, (company_dist, foreign_dist)))
return result
@api.ondelete(at_uninstall=False)
def _unlink_except_referenced_terms(self):
if self.env['account.move'].search([('invoice_payment_term_id', 'in', self.ids)]):
raise UserError(_('You can not delete payment terms as other records still reference it. However, you can archive it.'))
def unlink(self):
for terms in self:
self.env['ir.property'].sudo().search(
[('value_reference', 'in', ['account.payment.term,%s'%payment_term.id for payment_term in terms])]
).unlink()
return super(AccountPaymentTerm, self).unlink()
class AccountPaymentTermLine(models.Model):
_name = "account.payment.term.line"
_description = "Payment Terms Line"
_order = "sequence, id"
value = fields.Selection([
('balance', 'Balance'),
('percent', 'Percent'),
('fixed', 'Fixed Amount')
], string='Type', required=True, default='balance',
help="Select here the kind of valuation related to this payment terms line.")
value_amount = fields.Float(string='Value', digits='Payment Terms', help="For percent enter a ratio between 0-100.")
days = fields.Integer(string='Number of Days', required=True, default=0)
day_of_the_month = fields.Integer(string='Day of the month', help="Day of the month on which the invoice must come to its term. If zero or negative, this value will be ignored, and no specific day will be set. If greater than the last day of a month, this number will instead select the last day of this month.")
option = fields.Selection([
('day_after_invoice_date', "days after the invoice date"),
('after_invoice_month', "days after the end of the invoice month"),
('day_following_month', "of the following month"),
('day_current_month', "of the current month"),
],
default='day_after_invoice_date', required=True, string='Options'
)
payment_id = fields.Many2one('account.payment.term', string='Payment Terms', required=True, index=True, ondelete='cascade')
sequence = fields.Integer(default=10, help="Gives the sequence order when displaying a list of payment terms lines.")
@api.constrains('value', 'value_amount')
def _check_percent(self):
for term_line in self:
if term_line.value == 'percent' and (term_line.value_amount < 0.0 or term_line.value_amount > 100.0):
raise ValidationError(_('Percentages on the Payment Terms lines must be between 0 and 100.'))
@api.constrains('days')
def _check_days(self):
for term_line in self:
if term_line.option in ('day_following_month', 'day_current_month') and term_line.days <= 0:
raise ValidationError(_("The day of the month used for this term must be strictly positive."))
elif term_line.days < 0:
raise ValidationError(_("The number of days used for a payment term cannot be negative."))
@api.onchange('option')
def _onchange_option(self):
if self.option in ('day_current_month', 'day_following_month'):
self.days = 0