From cb40b16185b0fb32d6b60e57d8b262d0f5fb28a4 Mon Sep 17 00:00:00 2001 From: Alessandro Uffreduzzi Date: Mon, 19 Aug 2024 11:43:23 +0200 Subject: [PATCH] [IMP]l10n_it_delivery_note: split move lines based on dn --- .../models/account_invoice.py | 73 +++++------ l10n_it_delivery_note/models/sale_order.py | 120 +++++++++++++----- .../models/stock_delivery_note.py | 8 +- .../test_stock_delivery_note_invoicing.py | 110 +++++++++++++++- 4 files changed, 235 insertions(+), 76 deletions(-) diff --git a/l10n_it_delivery_note/models/account_invoice.py b/l10n_it_delivery_note/models/account_invoice.py index 93937e3a4c72..d3a2ebec07ce 100644 --- a/l10n_it_delivery_note/models/account_invoice.py +++ b/l10n_it_delivery_note/models/account_invoice.py @@ -6,9 +6,11 @@ # @author: Matteo Bilotta # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from collections import defaultdict + from odoo import _, fields, models -from .stock_delivery_note import DATE_FORMAT, DOMAIN_INVOICE_STATUSES +from .stock_delivery_note import DATE_FORMAT class AccountInvoice(models.Model): @@ -87,13 +89,27 @@ def _prepare_note_dn_value(self, sequence, delivery_note_id): "quantity": 0, } + def _has_dn_line_note(self, delivery_note): + self.ensure_one() + return bool( + self.invoice_line_ids.filtered( + lambda line, dn=delivery_note: line.display_type == "line_note" + and line.delivery_note_id == dn + ) + ) + def update_delivery_note_lines(self): context = {} for invoice in self.filtered(lambda i: i.delivery_note_ids): + sequence = 1 new_lines = [] - old_lines = invoice.invoice_line_ids.filtered(lambda l: l.note_dn) - old_lines.unlink() + + # Build a dictionary {delivery.note(1, 2): account.move.line(3, 5)} + inv_line_by_dn = defaultdict(self.env["account.move.line"].browse) + for inv_line in invoice.invoice_line_ids: + dn = inv_line.delivery_note_line_id.delivery_note_id + inv_line_by_dn[dn] |= inv_line # # TODO: Come bisogna comportarsi nel caso in @@ -111,7 +127,9 @@ def update_delivery_note_lines(self): # context["lang"] = invoice.partner_id.lang - if len(invoice.delivery_note_ids) == 1: + if len(invoice.delivery_note_ids) == 1 and not invoice._has_dn_line_note( + invoice.delivery_note_ids[0] + ): sequence = invoice.invoice_line_ids[0].sequence - 1 new_lines.append( ( @@ -123,40 +141,20 @@ def update_delivery_note_lines(self): ) ) else: - sequence = 1 - done_invoice_lines = self.env["account.move.line"] - for dn in invoice.mapped("delivery_note_ids").sorted(key="name"): - dn_invoice_lines = invoice.invoice_line_ids.filtered( - lambda x: x not in done_invoice_lines - and dn - in x.mapped( - "sale_line_ids.delivery_note_line_ids.delivery_note_id" + for dn in inv_line_by_dn: + if not dn or invoice._has_dn_line_note(dn): + continue + new_lines_vals = self._prepare_note_dn_value(sequence, dn) + new_lines.append( + ( + 0, + False, + new_lines_vals, ) - # fixme test invoice from 2 sale lines ) - done_invoice_lines |= dn_invoice_lines - for note_line in dn.line_ids.filtered( - lambda l: l.invoice_status == DOMAIN_INVOICE_STATUSES[2] - ): - for invoice_line in dn_invoice_lines: - if ( - note_line - in invoice_line.sale_line_ids.delivery_note_line_ids - ): - invoice_line.delivery_note_id = ( - note_line.delivery_note_id.id - ) - if dn_invoice_lines: - new_lines.append( - ( - 0, - False, - self._prepare_note_dn_value(sequence, dn), - ) - ) - sequence += 1 - for invoice_line in dn_invoice_lines: - invoice_line.sequence = sequence + sequence += 1 + for line in inv_line_by_dn[dn]: + line.sequence = sequence sequence += 1 invoice.write({"line_ids": new_lines}) @@ -192,4 +190,7 @@ class AccountInvoiceLine(models.Model): delivery_note_id = fields.Many2one( "stock.delivery.note", string="Delivery Note", readonly=True, copy=False ) + delivery_note_line_id = fields.Many2one( + "stock.delivery.note.line", string="Delivery Note", readonly=True, copy=False + ) note_dn = fields.Boolean(string="Note DN") diff --git a/l10n_it_delivery_note/models/sale_order.py b/l10n_it_delivery_note/models/sale_order.py index c401f7885909..e85fd3e0b3e4 100644 --- a/l10n_it_delivery_note/models/sale_order.py +++ b/l10n_it_delivery_note/models/sale_order.py @@ -1,7 +1,8 @@ # Copyright (c) 2019, Link IT Europe Srl # @author: Matteo Bilotta -from odoo import api, fields, models +from odoo import _, api, fields, models +from odoo.exceptions import UserError from .stock_delivery_note import DOMAIN_DELIVERY_NOTE_STATES, DOMAIN_INVOICE_STATUSES @@ -52,63 +53,116 @@ def onchange_partner_id_shipping_info(self): self.update(values) - def _assign_delivery_notes_invoices(self, invoice_ids): + def _cancel_delivery_note_lines(self): order_lines = self.mapped("order_line").filtered( lambda l: l.is_invoiced and l.delivery_note_line_ids ) - delivery_note_lines = order_lines.mapped("delivery_note_line_ids").filtered( lambda l: l.is_invoiceable ) delivery_notes = delivery_note_lines.mapped("delivery_note_id") - ready_delivery_notes = delivery_notes.filtered( lambda n: n.state != DOMAIN_DELIVERY_NOTE_STATES[0] ) - draft_delivery_notes = delivery_notes - ready_delivery_notes draft_delivery_note_lines = ( draft_delivery_notes.mapped("line_ids") & delivery_note_lines ) - - ready_delivery_note_lines = delivery_note_lines - draft_delivery_note_lines - - # - # TODO: È necessario gestire il caso di fatturazione splittata - # di una stessa riga d'ordine associata ad una sola - # picking (e di conseguenza, ad un solo DdT)? - # Può essere, invece, un caso "borderline" - # da lasciar gestire all'operatore? - # Personalmente, non lo gestirei e delegherei - # all'operatore questa responsabilità... - # - draft_delivery_note_lines.write( {"invoice_status": DOMAIN_INVOICE_STATUSES[0], "sale_line_id": None} ) - ready_delivery_note_lines.write({"invoice_status": DOMAIN_INVOICE_STATUSES[2]}) - for ready_delivery_note in ready_delivery_notes: - ready_invoice_ids = [ - invoice_id - for invoice_id in ready_delivery_note.sale_ids.mapped("invoice_ids").ids - if invoice_id in invoice_ids - ] - ready_delivery_note.write( - {"invoice_ids": [(4, invoice_id) for invoice_id in ready_invoice_ids]} + def _assign_delivery_notes_invoices(self, invoice_ids): + if not invoice_ids: + return + + delivery_note_ids = self.env.context.get("delivery_note_ids") + self._cancel_delivery_note_lines() + + all_invoice_lines = invoice_ids.invoice_line_ids + for sol in self.order_line: + if not (sol.qty_invoiced and sol.delivery_note_line_ids): + continue + dn_lines = sol.delivery_note_line_ids.filtered( + lambda l: l.is_invoiceable + and l.delivery_note_id.state + not in ( + DOMAIN_DELIVERY_NOTE_STATES[0], # draft + DOMAIN_DELIVERY_NOTE_STATES[-1], # cancel + ) ) - - ready_delivery_notes._compute_invoice_status() + if not dn_lines: + continue + + if dn_lines and delivery_note_ids: + dn_lines = dn_lines.filtered( + lambda l: l.delivery_note_id in delivery_note_ids + ) + + inv_lines = all_invoice_lines.filtered( + lambda line, s=sol: s in line.sale_line_ids + ).with_context(check_move_validity=False) + inv_line = inv_lines[0] # safety guard + inv_line.write( + { + "delivery_note_line_id": dn_lines[0], + "delivery_note_id": dn_lines[0].delivery_note_id, + } + ) + if len(dn_lines) > 1: + inv_line.quantity = dn_lines[0].product_qty + move_id = inv_line.move_id + remaining_dn_lines = dn_lines[1:] + product = sol.product_id + for dn_line in remaining_dn_lines: + move_line = dn_line.move_id.move_line_ids.filtered( + lambda l, p=product: l.product_id == p + ) + if len(move_line) != 1: + raise UserError( + _( + "No unique matching move line was found for %(sol)s in" + " Stock Move %(move)s" + ) + % { + "sol": sol.name, + "move": dn_line.move_id.name, + } + ) + new_data = inv_line.copy_data( + { + "quantity": move_line.qty_done, + "price_unit": inv_line.price_unit, + "delivery_note_line_id": dn_line.id, + "delivery_note_id": dn_line.delivery_note_id.id, + "sale_line_ids": [(6, 0, inv_line.sale_line_ids.ids)], + } + )[0] + move_id.write({"invoice_line_ids": [(0, 0, new_data)]}) + # We are setting `inv_line.quantity` again because + # `_move_autocomplete_invoice_lines_write()` applies the new changes on + # a temporary copy of the original invoice that fetches outdated data + # thus requiring a second write + inv_line.quantity = dn_lines[0].product_qty + move_id._onchange_invoice_line_ids() + dn_lines.write( + { + "invoice_status": DOMAIN_INVOICE_STATUSES[2], + } + ) + for dn in dn_lines.mapped("delivery_note_id"): + dn.invoice_ids += inv_lines.mapped("move_id") + dn._compute_invoice_status() + invoice_ids._check_balanced() def _generate_delivery_note_lines(self, invoice_ids): - invoices = self.env["account.move"].browse(invoice_ids) - invoices.update_delivery_note_lines() + invoice_ids.update_delivery_note_lines() def _create_invoices(self, grouped=False, final=False, date=None): invoice_ids = super()._create_invoices(grouped=grouped, final=final, date=date) - self._assign_delivery_notes_invoices(invoice_ids.ids) - self._generate_delivery_note_lines(invoice_ids.ids) + self._assign_delivery_notes_invoices(invoice_ids) + self._generate_delivery_note_lines(invoice_ids) return invoice_ids diff --git a/l10n_it_delivery_note/models/stock_delivery_note.py b/l10n_it_delivery_note/models/stock_delivery_note.py index ec95237d3ab3..3bfe2873e4ff 100644 --- a/l10n_it_delivery_note/models/stock_delivery_note.py +++ b/l10n_it_delivery_note/models/stock_delivery_note.py @@ -754,9 +754,11 @@ def action_invoice(self, invoice_method=False): if order_lines.filtered(lambda l: l.need_to_be_invoiced): cache[downpayment] = downpayment.fix_qty_to_invoice() - invoice_ids = sale_ids.filtered( - lambda o: o.invoice_status == DOMAIN_INVOICE_STATUSES[1] - )._create_invoices(final=True) + invoice_ids = ( + sale_ids.with_context(delivery_note_ids=self) + .filtered(lambda o: o.invoice_status == DOMAIN_INVOICE_STATUSES[1]) + ._create_invoices(final=True) + ) for line, vals in cache.items(): line.write(vals) diff --git a/l10n_it_delivery_note/tests/test_stock_delivery_note_invoicing.py b/l10n_it_delivery_note/tests/test_stock_delivery_note_invoicing.py index 1a9a0b638c42..3ba7df8ccbae 100644 --- a/l10n_it_delivery_note/tests/test_stock_delivery_note_invoicing.py +++ b/l10n_it_delivery_note/tests/test_stock_delivery_note_invoicing.py @@ -1356,10 +1356,12 @@ def test_invoicing_multiple_dn(self): invoice.action_post() self.assertEqual(invoice.state, "posted") self.assertEqual( - invoice.invoice_line_ids.filtered( - lambda inv_line: inv_line.product_id.id - == self.right_corner_desk_line[2]["product_id"] - ).quantity, + sum( + invoice.invoice_line_ids.filtered( + lambda inv_line: inv_line.product_id.id + == self.right_corner_desk_line[2]["product_id"] + ).mapped("quantity") + ), 2, ) self.assertEqual( @@ -1379,6 +1381,20 @@ def test_invoicing_multiple_dn(self): f'Delivery Note "{back_dn.name}" of {back_dn.date.strftime(DATE_FORMAT)}', invoice.invoice_line_ids.mapped("name"), ) + invoice_lines = invoice.invoice_line_ids.sorted("sequence") + for delivery_note in invoice_lines.mapped("delivery_note_id"): + inv_dn_lines = invoice_lines.filtered( + lambda l, dn=delivery_note: l.delivery_note_id == dn + ) + note_line = inv_dn_lines.filtered(lambda l: l.note_dn) + self.assertEqual(len(note_line), 1) + # Check that the first line of a particular dn is a note + self.assertEqual(inv_dn_lines[0], note_line) + # Check that all lines of a particular dn are neighbouring each other + self.assertEqual( + len(inv_dn_lines), + int(inv_dn_lines[-1].sequence) - int(inv_dn_lines[0].sequence) + 1, + ) def test_invoicing_multi_dn_multi_so_same_product(self): self.env["ir.config_parameter"].sudo().set_param( @@ -1473,3 +1489,89 @@ def test_invoicing_multi_dn_multi_so_same_product(self): self.assertIn(dn_2.date.strftime(DATE_FORMAT), label_so_2.name) self.assertEqual(product_line_2.product_id, so_2.order_line.product_id) self.assertEqual(product_line_2.quantity, 2) + + def test_invoicing_multi_partial_dn_multi_so_same_product(self): + self.env["ir.config_parameter"].sudo().set_param( + "l10n_it_delivery_note.group_use_advanced_delivery_notes", True + ) + # SO 1 + so_1 = self.create_sales_order([self.right_corner_desk_line]) # qty 2 + so_1.action_confirm() + + # create picking 1.1 + picking_1_1 = so_1.picking_ids + picking_1_1.move_lines[0].quantity_done = 1 + res_dict = picking_1_1.button_validate() + + # create picking 1.2 + wizard = Form( + self.env[(res_dict.get("res_model"))].with_context(res_dict["context"]) + ).save() + wizard.process() + + # create delivery note 1.1 + res_dict = picking_1_1.action_delivery_note_create() + wizard = Form( + self.env[(res_dict.get("res_model"))].with_context(res_dict["context"]) + ).save() + wizard.confirm() + dn_1_1 = picking_1_1.delivery_note_id + self.assertTrue(dn_1_1) + self.assertEqual(dn_1_1.partner_id, self.recipient) + dn_1_1.action_confirm() + dn_1_1.action_done() + + # deliver picking 1.2 (but not confirm) + picking_1_2 = picking_1_1.backorder_ids + picking_1_2.move_lines[0].quantity_done = 1 + picking_1_2.button_validate() + res_dict = picking_1_2.action_delivery_note_create() + wizard = Form( + self.env[(res_dict.get("res_model"))].with_context(res_dict["context"]) + ).save() + wizard.confirm() + dn_common = picking_1_2.delivery_note_id + + # SO 2 + so_2 = self.create_sales_order([self.right_corner_desk_line]) # qty 2 + so_2.action_confirm() + + # picking 2.1 + picking_2_1 = so_2.picking_ids + picking_2_1.move_lines[0].quantity_done = 1 + res_dict = picking_2_1.button_validate() + + # picking 2.2 + wizard = Form( + self.env[(res_dict.get("res_model"))].with_context(res_dict["context"]) + ).save() + wizard.process() + picking_2_2 = picking_2_1.backorder_ids + + # create delivery note 2.1 + res_dict = picking_2_1.action_delivery_note_create() + wizard = Form( + self.env[(res_dict.get("res_model"))].with_context(res_dict["context"]) + ).save() + wizard.confirm() + dn_2_1 = picking_2_1.delivery_note_id + dn_2_1.action_confirm() + dn_2_1.action_done() + + # deliver 2.2 + picking_2_2.move_lines[0].quantity_done = 1 + picking_2_2.button_validate() + picking_2_2.delivery_note_id = dn_common + dn_common.action_confirm() + dn_common.action_done() + self.assertEqual(picking_1_2.delivery_note_id, picking_2_2.delivery_note_id) + + # invoice backorder of so_1 and so_2 together + (so_1 | so_2)._create_invoices() + self.assertEqual(so_1.invoice_ids, so_2.invoice_ids) + invoice_lines = so_1.invoice_ids.invoice_line_ids.sorted("sequence") + dn_inv_lines = invoice_lines.filtered(lambda l: l.note_dn) + product_inv_lines = invoice_lines - dn_inv_lines + self.assertEqual(len(dn_inv_lines), 3) + self.assertEqual(len(product_inv_lines), 4) + self.assertTrue(all(ln == 1 for ln in product_inv_lines.mapped("quantity")))