Skip to content

Commit 4bcfd5c

Browse files
committed
[IMP] account,sale: move amount_to_invoice computation to SO line
In 16.2, the amount to invoice was introduced to effectively compute the total receivable of a partner. The goal was to take into account the confirmed SOs that are not invoiced yet, in order to trigger the partner's credit limit warning. Since then: - The total receivable of a partner = unpaid posted invoices + SOs amount to invoice. - Where the amount to invoice = SO total - confirmed down payments. The issue with that method is that we go against the Sales philosophy, which considers that a SOL (Sales Order Line) is invoiced as soon as the invoiced quantity (whatever the invoice status) equals the ordered quantity. That can lead to incoherent situations. For example: Let's imagine a SO with 2 products: - 3 desks at €100 - 5 chairs at €50. We invoice 3 desks at 90€ because of a price change and 5 chairs at 50€. Sales will consider the SO to be done ("nothing to invoice"), whereas Accounting consider that there's still €30 to invoice. The bottom line is that: - We should consider the amount to invoice per SOL and not per SO total. - We should remain consistent with the Sales methodology, to avoid weird cases as demonstrated before. task-3764582 Part-of: odoo#164963 Related: odoo/enterprise#62208 Related: odoo/upgrade#6425 Signed-off-by: William André (wan) <[email protected]>
1 parent c75dc50 commit 4bcfd5c

File tree

7 files changed

+124
-33
lines changed

7 files changed

+124
-33
lines changed

addons/account/models/account_move.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1596,7 +1596,7 @@ def _compute_partner_credit_warning(self):
15961596
move.partner_credit_warning = ''
15971597
show_warning = move.state == 'draft' and \
15981598
move.move_type == 'out_invoice' and \
1599-
move.company_id.account_use_credit_limit
1599+
self.env.company.account_use_credit_limit
16001600
if show_warning:
16011601
total_field = 'amount_total' if move.currency_id == move.company_currency_id else 'amount_total_company_currency'
16021602
current_amount = move.tax_totals[total_field]

addons/sale/models/res_partner.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,20 +74,22 @@ def _compute_credit_to_invoice(self):
7474
# EXTENDS 'account'
7575
super()._compute_credit_to_invoice()
7676
company = self.env.company
77-
domain = [
77+
if not company.account_use_credit_limit:
78+
return
79+
80+
sale_orders = self.env['sale.order'].search([
7881
('company_id', '=', company.id),
7982
('partner_id', 'in', self.ids),
80-
('amount_to_invoice', '>', 0),
81-
('state', '=', 'sale')
82-
]
83-
84-
group = self.env['sale.order']._read_group(domain, ['partner_id', 'currency_id'], ['amount_to_invoice:sum'])
85-
for partner, currency, amount_to_invoice_sum in group:
83+
('order_line', 'any', [('untaxed_amount_to_invoice', '>', 0)]),
84+
('state', '=', 'sale'),
85+
])
86+
for (partner, currency), orders in sale_orders.grouped(lambda so: (so.partner_id, so.currency_id)).items():
87+
amount_to_invoice_sum = sum(orders.mapped('amount_to_invoice'))
8688
credit_company_currency = currency._convert(
8789
amount_to_invoice_sum,
8890
company.currency_id,
8991
company,
90-
fields.Date.context_today(self)
92+
fields.Date.context_today(self),
9193
)
9294
partner.credit_to_invoice += credit_company_currency
9395

addons/sale/models/sale_order.py

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ def _rec_names_search(self):
220220
amount_untaxed = fields.Monetary(string="Untaxed Amount", store=True, compute='_compute_amounts', tracking=5)
221221
amount_tax = fields.Monetary(string="Taxes", store=True, compute='_compute_amounts')
222222
amount_total = fields.Monetary(string="Total", store=True, compute='_compute_amounts', tracking=4)
223-
amount_to_invoice = fields.Monetary(string="Amount to invoice", store=True, compute='_compute_amount_to_invoice')
223+
amount_to_invoice = fields.Monetary(string="Un-invoiced Balance", compute='_compute_amount_to_invoice')
224224
amount_invoiced = fields.Monetary(string="Already invoiced", compute='_compute_amount_invoiced')
225225

226226
invoice_count = fields.Integer(string="Invoice Count", compute='_get_invoiced')
@@ -650,32 +650,26 @@ def _compute_tax_country_id(self):
650650
else:
651651
record.tax_country_id = record.company_id.account_fiscal_country_id
652652

653-
@api.depends('invoice_ids.state', 'currency_id', 'amount_total')
653+
@api.depends('order_line.amount_to_invoice')
654654
def _compute_amount_to_invoice(self):
655-
for order in self:
656-
# If the invoice status is 'Fully Invoiced' force the amount to invoice to equal zero and return early.
657-
if order.invoice_status == 'invoiced':
658-
order.amount_to_invoice = 0.0
659-
continue
660-
661-
invoices = order.invoice_ids.filtered(lambda x: x.state == 'posted')
662-
# Note: A negative amount can happen, since we can invoice more than the sales order amount.
663-
# Care has to be taken when summing amount_to_invoice of multiple orders.
664-
# E.g. consider one invoiced order with -100 and one uninvoiced order of 100: 100 + -100 = 0
665-
order.amount_to_invoice = order.amount_total - invoices._get_sale_order_invoiced_amount(order)
655+
if not self.env.company.account_use_credit_limit:
656+
self.amount_to_invoice = 0.0
657+
else:
658+
for order in self:
659+
order.amount_to_invoice = sum(order.order_line.mapped('amount_to_invoice'))
666660

667-
@api.depends('amount_total', 'amount_to_invoice')
661+
@api.depends('order_line.amount_invoiced')
668662
def _compute_amount_invoiced(self):
669663
for order in self:
670-
order.amount_invoiced = order.amount_total - order.amount_to_invoice
664+
order.amount_invoiced = sum(order.order_line.mapped('amount_invoiced'))
671665

672666
@api.depends('company_id', 'partner_id', 'amount_total')
673667
def _compute_partner_credit_warning(self):
674668
for order in self:
675669
order.with_company(order.company_id)
676670
order.partner_credit_warning = ''
677671
show_warning = order.state in ('draft', 'sent') and \
678-
order.company_id.account_use_credit_limit
672+
self.env.company.account_use_credit_limit
679673
if show_warning:
680674
order.partner_credit_warning = self.env['account.move']._build_credit_warning_message(
681675
order,

addons/sale/models/sale_order_line.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,10 @@ class SaleOrderLine(models.Model):
243243
compute='_compute_qty_invoiced',
244244
digits='Product Unit of Measure',
245245
store=True)
246+
qty_invoiced_posted = fields.Float(
247+
string="Invoiced Quantity (posted)",
248+
compute='_compute_qty_invoiced_posted',
249+
digits='Product Unit of Measure')
246250
qty_to_invoice = fields.Float(
247251
string="Quantity To Invoice",
248252
compute='_compute_qty_to_invoice',
@@ -273,10 +277,16 @@ class SaleOrderLine(models.Model):
273277
string="Untaxed Invoiced Amount",
274278
compute='_compute_untaxed_amount_invoiced',
275279
store=True)
280+
amount_invoiced = fields.Monetary(
281+
string="Invoiced Amount",
282+
compute='_compute_amount_invoiced')
276283
untaxed_amount_to_invoice = fields.Monetary(
277284
string="Untaxed Amount To Invoice",
278285
compute='_compute_untaxed_amount_to_invoice',
279286
store=True)
287+
amount_to_invoice = fields.Monetary(
288+
string="Amount to invoice",
289+
compute='_compute_amount_to_invoice')
280290

281291
# Technical computed fields for UX purposes (hide/make fields readonly, ...)
282292
product_type = fields.Selection(related='product_id.type', depends=['product_id'])
@@ -887,6 +897,23 @@ def _compute_qty_invoiced(self):
887897
qty_invoiced -= invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom)
888898
line.qty_invoiced = qty_invoiced
889899

900+
@api.depends('invoice_lines.move_id.state', 'invoice_lines.quantity')
901+
def _compute_qty_invoiced_posted(self):
902+
"""
903+
This method is almost identical to '_compute_qty_invoiced()'. The only difference lies in the fact that
904+
for accounting purposes, we only want the quantities of the posted invoices.
905+
We need a dedicated computation because the triggers are different and could lead to incorrect values for
906+
'qty_invoiced' when computed together.
907+
"""
908+
for line in self:
909+
qty_invoiced_posted = 0.0
910+
for invoice_line in line._get_invoice_lines():
911+
if invoice_line.move_id.state == 'posted':
912+
qty_unsigned = invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom)
913+
qty_signed = qty_unsigned * -invoice_line.move_id.direction_sign
914+
qty_invoiced_posted += qty_signed
915+
line.qty_invoiced_posted = qty_invoiced_posted
916+
890917
def _get_invoice_lines(self):
891918
self.ensure_one()
892919
if self._context.get('accrual_entry_date'):
@@ -973,6 +1000,18 @@ def _compute_untaxed_amount_invoiced(self):
9731000
amount_invoiced -= invoice_line.currency_id._convert(invoice_line.price_subtotal, line.currency_id, line.company_id, invoice_date)
9741001
line.untaxed_amount_invoiced = amount_invoiced
9751002

1003+
@api.depends('invoice_lines', 'invoice_lines.price_total', 'invoice_lines.move_id.state')
1004+
def _compute_amount_invoiced(self):
1005+
for line in self:
1006+
amount_invoiced = 0.0
1007+
for invoice_line in line._get_invoice_lines():
1008+
invoice = invoice_line.move_id
1009+
if invoice.state == 'posted':
1010+
invoice_date = invoice.invoice_date or fields.Date.context_today(self)
1011+
amount_invoiced_unsigned = invoice_line.currency_id._convert(invoice_line.price_total, line.currency_id, line.company_id, invoice_date)
1012+
amount_invoiced += amount_invoiced_unsigned * -invoice.direction_sign
1013+
line.amount_invoiced = amount_invoiced
1014+
9761015
@api.depends('state', 'product_id', 'untaxed_amount_invoiced', 'qty_delivered', 'product_uom_qty', 'price_unit')
9771016
def _compute_untaxed_amount_to_invoice(self):
9781017
""" Total of remaining amount to invoice on the sale order line (taxes excl.) as
@@ -1021,6 +1060,20 @@ def _compute_untaxed_amount_to_invoice(self):
10211060

10221061
line.untaxed_amount_to_invoice = amount_to_invoice
10231062

1063+
@api.depends('discount', 'price_total', 'product_uom_qty', 'qty_delivered', 'qty_invoiced_posted')
1064+
def _compute_amount_to_invoice(self):
1065+
if not self.company_id.account_use_credit_limit:
1066+
self.amount_to_invoice = 0.0
1067+
for line in self:
1068+
if line.product_uom_qty:
1069+
uom_qty_to_consider = line.qty_delivered if line.product_id.invoice_policy == 'delivery' else line.product_uom_qty
1070+
qty_to_invoice = uom_qty_to_consider - line.qty_invoiced_posted
1071+
unit_price_total = line.price_total / line.product_uom_qty
1072+
price_reduce = unit_price_total * (1 - (line.discount or 0.0) / 100.0)
1073+
line.amount_to_invoice = price_reduce * qty_to_invoice
1074+
else:
1075+
line.amount_to_invoice = 0.0
1076+
10241077
@api.depends('order_id.partner_id', 'product_id')
10251078
def _compute_analytic_distribution(self):
10261079
for line in self:

addons/sale/tests/test_credit_limit.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,10 @@ def test_credit_limit_multi_company(self):
3535
# multi-company setup
3636
company2 = self.company_data_2['company']
3737

38-
# Activate the Credit Limit feature and set a value for partner_a.
39-
self.env.company.account_use_credit_limit = True
40-
self.partner_a.credit_limit = 1000.0
38+
# Activate the Credit Limit feature
39+
company2.account_use_credit_limit = True
4140

42-
# Create and confirm a SO for another company
41+
# Create and confirm a SO for that company
4342
sale_order = company2.env['sale.order'].create({
4443
'company_id': company2.id,
4544
'partner_id': self.partner_a.id,
@@ -56,6 +55,7 @@ def test_credit_limit_multi_company(self):
5655
sale_order.action_confirm()
5756

5857
self.partner_a.invalidate_recordset(['credit', 'credit_to_invoice'])
58+
self.assertEqual(self.partner_a.credit_to_invoice, 0.0)
5959
self.assertEqual(self.partner_a.with_company(company2).credit_to_invoice, 1000.0)
6060
partner_a_multi_company = self.partner_a.with_context(allowed_company_ids=[self.env.company.id, company2.id])
6161
self.assertEqual(partner_a_multi_company.credit_to_invoice, 0.0)

addons/sale/tests/test_sale_to_invoice.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,7 @@ def test_amount_to_invoice_multiple_so(self):
938938
""" Testing creating two SOs with the same customer and invoicing them together. We have to ensure
939939
that the amount to invoice is correct for each SO.
940940
"""
941+
self.env.company.account_use_credit_limit = True
941942
sale_order_1 = self.env['sale.order'].create({
942943
'partner_id': self.partner_a.id,
943944
'order_line': [
@@ -976,6 +977,7 @@ def test_amount_to_invoice_one_line_multiple_so(self):
976977
""" Testing creating two SOs linked to the same invoice line. Drawback: the substracted
977978
amount to the amount_total will take both sale order into account.
978979
"""
980+
self.env.company.account_use_credit_limit = True
979981
sale_order_1 = self.env['sale.order'].create({
980982
'partner_id': self.partner_a.id,
981983
'order_line': [
@@ -1012,3 +1014,47 @@ def test_amount_to_invoice_one_line_multiple_so(self):
10121014

10131015
self.assertEqual(sale_order_1.amount_to_invoice, -700.0)
10141016
self.assertEqual(sale_order_2.amount_to_invoice, 0.0)
1017+
1018+
def test_amount_to_invoice_price_unit_change(self):
1019+
"""
1020+
We check that the 'amount_to_invoice' relies only on the posted invoice quantity,
1021+
and is not affected by price changes that occurred during invoice creation.
1022+
"""
1023+
self.env.company.account_use_credit_limit = True
1024+
so = self.env['sale.order'].create({
1025+
'partner_id': self.partner_a.id,
1026+
'partner_invoice_id': self.partner_a.id,
1027+
'partner_shipping_id': self.partner_a.id,
1028+
'pricelist_id': self.company_data['default_pricelist'].id,
1029+
})
1030+
1031+
sol_prod_deliver = self.env['sale.order.line'].create({
1032+
'product_id': self.company_data['product_order_no'].id,
1033+
'product_uom_qty': 5,
1034+
'order_id': so.id,
1035+
'tax_id': False,
1036+
})
1037+
1038+
so.action_confirm()
1039+
sol_prod_deliver.write({'qty_delivered': 5.0})
1040+
1041+
invoice_vals = self.env['sale.advance.payment.inv'].create({
1042+
'advance_payment_method': 'delivered',
1043+
'sale_order_ids': [Command.set(so.ids)],
1044+
}).create_invoices()
1045+
1046+
# Invoice is created in draft, which should impact 'qty_invoiced', but not 'amount_to_invoice'.
1047+
self.assertEqual(sol_prod_deliver.qty_invoiced, 5.0)
1048+
self.assertEqual(sol_prod_deliver.amount_to_invoice, sol_prod_deliver.price_total)
1049+
self.assertEqual(sol_prod_deliver.amount_invoiced, 0.0)
1050+
1051+
# Then we change the 'price_unit' on the invoice (keeping the quantity untouched).
1052+
invoice = self.env[invoice_vals['res_model']].browse(invoice_vals['res_id'])
1053+
invoice.invoice_line_ids.price_unit /= 2
1054+
invoice.action_post()
1055+
1056+
# In the end, the 'amount_to_invoice' should be 0.0, since all quantities have been invoiced,
1057+
# even if the price was changed manually on the invoice.
1058+
self.assertEqual(sol_prod_deliver.qty_invoiced, 5.0)
1059+
self.assertEqual(sol_prod_deliver.amount_to_invoice, 0.0)
1060+
self.assertEqual(sol_prod_deliver.amount_invoiced, sol_prod_deliver.price_total / 2)

addons/sale/views/sale_order_views.xml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,6 @@
156156
decoration-warning="invoice_status == 'upselling'"
157157
widget="badge"
158158
optional="show"/>
159-
<field name="amount_to_invoice" optional="hide"/>
160159
<field name="client_order_ref" optional="hide"/>
161160
<field name="validity_date" optional="hide"/>
162161
</list>
@@ -196,9 +195,6 @@
196195
<field name="invoice_status" position="attributes">
197196
<attribute name="optional">hide</attribute>
198197
</field>
199-
<field name="amount_to_invoice" position="attributes">
200-
<attribute name="column_invisible">1</attribute>
201-
</field>
202198
</field>
203199
</record>
204200

0 commit comments

Comments
 (0)