55# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
66
77import datetime
8+ from itertools import groupby
89
910from odoo import Command , api , fields , models
1011from odoo .exceptions import AccessError , UserError
@@ -701,6 +702,80 @@ def _prepare_invoice(self, sale_orders):
701702 )
702703 return invoice_vals
703704
705+ def _build_dn_map (self , sale_orders ):
706+ """Map delivery note lines by sale_line_id."""
707+ dn_map = {}
708+ for dn in self .sorted (key = lambda d : (d .date , d .name )):
709+ for line in dn .line_ids :
710+ if line .sale_line_id and (
711+ not sale_orders or line .sale_line_id .order_id in sale_orders
712+ ):
713+ dn_map .setdefault (line .sale_line_id .id , []).append ((dn , line ))
714+ return dn_map
715+
716+ def _append_dn_linked_lines (self , vals_list , dn_map , sequence , current_dn ):
717+ """Append product lines coming from delivery notes, grouped by DN."""
718+ account_move = self .env ["account.move" ]
719+
720+ for dn , dn_line in dn_map :
721+ if current_dn != dn :
722+ vals_list .append (
723+ Command .create (account_move ._prepare_note_dn_value (sequence , dn ))
724+ )
725+ sequence += 1
726+ current_dn = dn
727+
728+ if returned_moves := dn_line .mapped ("move_id.returned_move_ids" ):
729+ return_qty = sum (returned_moves .mapped ("quantity" ))
730+ product_qty = dn_line .product_qty - return_qty
731+ else :
732+ product_qty = dn_line .product_qty
733+
734+ vals = dn_line ._prepare_invoice_line (
735+ sequence = sequence ,
736+ quantity = product_qty ,
737+ )
738+ vals_list .append (Command .create (vals ))
739+ sequence += 1
740+
741+ return sequence , current_dn
742+
743+ def _append_sale_order_lines (self , vals_list , sale_orders , dn_map , sequence ):
744+ """Append invoice lines from sale orders preserving SO line order."""
745+ current_dn = None
746+ for order in sale_orders :
747+ so_lines = order .order_line .sorted (key = lambda ln : ln .sequence )
748+
749+ for line in so_lines :
750+ # Add SO lines notes/sections
751+ if line .display_type and not line .is_downpayment :
752+ vals = line ._prepare_invoice_line (sequence = sequence )
753+ vals_list .append (Command .create (vals ))
754+ sequence += 1
755+ continue
756+
757+ if line .id in dn_map and line .order_id == order :
758+ # Add delivery note lines
759+ sequence , current_dn = self ._append_dn_linked_lines (
760+ vals_list = vals_list ,
761+ dn_map = dn_map [line .id ],
762+ sequence = sequence ,
763+ current_dn = current_dn ,
764+ )
765+ else :
766+ # Add remaining SO lines not in delivery notes
767+ if (
768+ line .product_id .type != "service"
769+ and line .qty_to_invoice > 0
770+ and not line .is_downpayment
771+ ):
772+ vals = line ._prepare_invoice_line (sequence = sequence )
773+ vals ["sale_line_ids" ] = [Command .link (line .id )]
774+ vals_list .append (Command .create (vals ))
775+ sequence += 1
776+
777+ return sequence
778+
704779 def _prepare_invoice_lines (self , sale_orders , invoice_method , final ):
705780 """Creates invoice lines from delivery note lines,
706781 sorting delivery notes by date and name.
@@ -717,48 +792,16 @@ def _prepare_invoice_lines(self, sale_orders, invoice_method, final):
717792 Sets the created lines in the invoice values dictionary"""
718793 vals_list = []
719794 sequence = 1
720- account_move = self .env ["account.move" ]
721-
722- # Add delivery note lines as sections
723- for dn in self .sorted (key = lambda d : (d .date , d .name )):
724- vals_list .append (
725- Command .create (account_move ._prepare_note_dn_value (sequence , dn ))
726- )
727- sequence += 1
728795
729- # Get delivery note lines, filtered by sale_orders if provided
730- dn_line_ids = dn .line_ids
731- if sale_orders :
732- dn_line_ids = dn_line_ids .filtered (
733- lambda line : line .sale_line_id
734- and line .sale_line_id .order_id in sale_orders
735- )
796+ dn_map = self ._build_dn_map (sale_orders )
736797
737- # Add delivery note lines
738- for line in dn_line_ids :
739- if line .mapped ("move_id.returned_move_ids" ):
740- return_qty = sum (line .mapped ("move_id.returned_move_ids.quantity" ))
741- product_qty = line .product_qty - return_qty
742- else :
743- product_qty = line .product_qty
744- vals = line ._prepare_invoice_line (
745- sequence = sequence , quantity = product_qty
746- )
747- vals_list .append (Command .create (vals ))
748- sequence += 1
749-
750- # Add remaining SO lines not in delivery notes
751798 if sale_orders :
752- sale_lines = sale_orders .mapped ("order_line" ).filtered (
753- lambda ol : not ol .delivery_note_line_ids
754- and ol .product_id .type != "service"
755- and ol .qty_to_invoice > 0
799+ sequence = self ._append_sale_order_lines (
800+ vals_list = vals_list ,
801+ sale_orders = sale_orders ,
802+ dn_map = dn_map ,
803+ sequence = sequence ,
756804 )
757- for line in sale_lines :
758- vals = line ._prepare_invoice_line (sequence = sequence )
759- vals ["sale_line_ids" ] = [(4 , line .id )]
760- vals_list .append (Command .create (vals ))
761- sequence += 1
762805
763806 # Add service lines if requested
764807 if invoice_method == "service" :
@@ -769,7 +812,7 @@ def _prepare_invoice_lines(self, sale_orders, invoice_method, final):
769812 )
770813 for line in service_lines :
771814 vals = line ._prepare_invoice_line (sequence = sequence )
772- vals ["sale_line_ids" ] = [( 4 , line .id )]
815+ vals ["sale_line_ids" ] = [Command . link ( line .id )]
773816 vals_list .append (Command .create (vals ))
774817 sequence += 1
775818
@@ -799,7 +842,7 @@ def _prepare_invoice_lines(self, sale_orders, invoice_method, final):
799842
800843 return vals_list
801844
802- def _update_invoice_statuses (self , invoice , sale_orders ):
845+ def _update_invoice_statuses (self , invoices , sale_orders ):
803846 """Update invoice statuses after invoice creation"""
804847 # Get all delivery note lines with sale orders
805848 delivery_note_lines = self .mapped ("line_ids" ).filtered (
@@ -817,20 +860,92 @@ def _update_invoice_statuses(self, invoice, sale_orders):
817860
818861 # Link invoice to delivery notes
819862 for delivery_note in self :
820- delivery_note .write ({"invoice_ids" : [(4 , invoice .id )]})
863+ delivery_note .write (
864+ {"invoice_ids" : [Command .link (invoice .id ) for invoice in invoices ]}
865+ )
821866
822867 # Recompute overall invoice status
823868 self ._compute_invoice_status ()
824869
825- def action_invoice (self , invoice_method = False , final = False , sale_orders = None ):
870+ def _build_invoice_vals_list (
871+ self , sale_orders , delivery_notes , invoice_method = False , final = False
872+ ):
873+ invoice_vals_list = []
874+
875+ orders = delivery_notes .sale_ids & sale_orders
876+ invoice_vals = delivery_notes ._prepare_invoice (orders )
877+ invoice_vals ["invoice_line_ids" ] = delivery_notes ._prepare_invoice_lines (
878+ orders , invoice_method , final
879+ )
880+ invoice_vals_list .append (invoice_vals )
881+
882+ return invoice_vals_list
883+
884+ def _group_invoice_vals (self , invoice_vals_list , sale_orders ):
885+ new_invoice_vals_list = []
886+ invoice_grouping_keys = sale_orders ._get_invoice_grouping_keys ()
887+
888+ invoice_vals_list = sorted (
889+ invoice_vals_list ,
890+ key = lambda vals : [vals .get (k ) for k in invoice_grouping_keys ],
891+ )
892+
893+ for _keys , invoices in groupby (
894+ invoice_vals_list ,
895+ key = lambda vals : [vals .get (k ) for k in invoice_grouping_keys ],
896+ ):
897+ grouped_vals = None
898+ origins = set ()
899+ payment_refs = set ()
900+ refs = set ()
901+
902+ for invoice_vals in invoices :
903+ if not grouped_vals :
904+ grouped_vals = invoice_vals
905+ else :
906+ grouped_vals ["invoice_line_ids" ] += invoice_vals ["invoice_line_ids" ]
907+
908+ if invoice_vals .get ("invoice_origin" ):
909+ origins .add (invoice_vals ["invoice_origin" ])
910+ if invoice_vals .get ("payment_reference" ):
911+ payment_refs .add (invoice_vals ["payment_reference" ])
912+ if invoice_vals .get ("ref" ):
913+ refs .add (invoice_vals ["ref" ])
914+
915+ grouped_vals .update (
916+ {
917+ "ref" : ", " .join (refs )[:2000 ],
918+ "invoice_origin" : ", " .join (origins ),
919+ "payment_reference" : payment_refs .pop ()
920+ if len (payment_refs ) == 1
921+ else False ,
922+ }
923+ )
924+ new_invoice_vals_list .append (grouped_vals )
925+
926+ return new_invoice_vals_list
927+
928+ def action_invoice (
929+ self , invoice_method = False , final = False , sale_orders = None , grouped = False
930+ ):
826931 delivery_note_ids = self .filtered (
827932 lambda dn : dn .state == "confirm" and dn .invoice_status == "to invoice"
828933 )
829934 delivery_note_ids ._check_delivery_notes_before_invoicing ()
830935
831936 # If not passed explicitly, get all sale orders from delivery notes
832- if sale_orders is None :
833- sale_orders = delivery_note_ids .sale_ids
937+ sale_orders = sale_orders or delivery_note_ids .sale_ids
938+ if not sale_orders :
939+ return self .env ["account.move" ]
940+
941+ # Check if the user has access rights to create invoices
942+ if not self .env ["account.move" ].check_access ("create" ):
943+ try :
944+ self .check_access ("write" )
945+ except AccessError :
946+ return self .env ["account.move" ]
947+
948+ moves = self .env ["account.move" ]
834949
835950 # Group by payment term (include empty payment term)
836951 payment_terms = sale_orders .payment_term_id or [False ]
@@ -850,13 +965,6 @@ def action_invoice(self, invoice_method=False, final=False, sale_orders=None):
850965 if not filtered_sales :
851966 continue
852967
853- # Check if the user has access rights to create invoices
854- if not self .env ["account.move" ].check_access ("create" ):
855- try :
856- self .check_access ("write" )
857- except AccessError :
858- return self .env ["account.move" ]
859-
860968 # Filter delivery notes related to the sale orders
861969 filter_delivery_notes = delivery_note_ids .filtered (
862970 lambda dn , so = filtered_sales : all (
@@ -865,36 +973,58 @@ def action_invoice(self, invoice_method=False, final=False, sale_orders=None):
865973 or all (so_id in dn .sale_ids .ids for so_id in so .ids )
866974 )
867975
868- # Prepare invoice
869- invoice_vals = filter_delivery_notes ._prepare_invoice (filtered_sales )
870-
871- # Prepare invoice lines
872- vals_list = filter_delivery_notes ._prepare_invoice_lines (
873- filtered_sales , invoice_method , final
976+ # prepare invoice lines for every SO
977+ invoice_vals_list = self ._build_invoice_vals_list (
978+ filtered_sales , filter_delivery_notes , invoice_method , final
874979 )
980+ if not invoice_vals_list :
981+ continue
875982
876- # invoice creation
877- invoice_vals ["invoice_line_ids" ] = vals_list
878- invoice_id = (
879- self .env ["account.move" ]
880- .sudo ()
881- .with_context (default_move_type = "out_invoice" )
882- .create (invoice_vals )
883- )
983+ # GROUPING
984+ if not grouped :
985+ invoice_vals_list = self ._group_invoice_vals (
986+ invoice_vals_list , sale_orders
987+ )
988+
989+ for invoice_vals in invoice_vals_list :
990+ sequence = 1
991+ for line in invoice_vals .get ("invoice_line_ids" , []):
992+ if len (line ) >= 3 and isinstance (line [2 ], dict ):
993+ line [2 ]["sequence" ] = self .env [
994+ "sale.order.line"
995+ ]._get_invoice_line_sequence (
996+ new = sequence ,
997+ old = line [2 ].get ("sequence" ),
998+ )
999+ sequence += 1
8841000
885- # Update statuses
886- filter_delivery_notes ._update_invoice_statuses (invoice_id , filtered_sales )
1001+ # CREATE invoices
1002+ moves = self .env ["account.move" ]
1003+
1004+ for invoice_vals in invoice_vals_list :
1005+ move = (
1006+ self .env ["account.move" ]
1007+ .sudo ()
1008+ .with_context (default_move_type = "out_invoice" )
1009+ .create (invoice_vals )
1010+ )
1011+ moves |= move
1012+
1013+ # update delivery notes status
1014+ filter_delivery_notes ._update_invoice_statuses (moves , sale_orders )
8871015
8881016 # Some moves might actually be refunds: convert them if the total amount is
8891017 # negative.
8901018 # We do this after the moves have been created since we need taxes, etc.
8911019 # to know if the total is actually negative or not
8921020 if final and (
893- move_to_switch := invoice_id .sudo ().filtered (lambda m : m .amount_total < 0 )
1021+ moves_to_switch := moves .sudo ().filtered (lambda m : m .amount_total < 0 )
8941022 ):
895- with self .env .protecting ([invoice_id ._fields ["team_id" ]], move_to_switch ):
896- move_to_switch .action_switch_move_type ()
897- invoice_id ._set_reversed_entry (move_to_switch )
1023+ with self .env .protecting ([moves ._fields ["team_id" ]], moves_to_switch ):
1024+ moves_to_switch .action_switch_move_type ()
1025+ sale_orders .invoice_ids ._set_reversed_entry (moves_to_switch )
1026+
1027+ return moves
8981028
8991029 def action_done (self ):
9001030 self .write ({"state" : DOMAIN_DELIVERY_NOTE_STATES [3 ]})
@@ -972,7 +1102,7 @@ def _create_detail_lines(self, move_ids):
9721102 moves = self .env ["stock.move" ].browse (move_ids )
9731103 lines_vals = self .env ["stock.delivery.note.line" ]._prepare_detail_lines (moves )
9741104
975- self .write ({"line_ids" : [( 0 , False , vals ) for vals in lines_vals ]})
1105+ self .write ({"line_ids" : [Command . create ( vals ) for vals in lines_vals ]})
9761106
9771107 def _delete_detail_lines (self , move_ids ):
9781108 if not move_ids :
@@ -982,7 +1112,7 @@ def _delete_detail_lines(self, move_ids):
9821112 [("move_id" , "in" , move_ids )]
9831113 )
9841114
985- self .write ({"line_ids" : [( 2 , line .id , False ) for line in lines ]})
1115+ self .write ({"line_ids" : [Command . delete ( line .id ) for line in lines ]})
9861116
9871117 def update_detail_lines (self ):
9881118 for note in self :
0 commit comments