1# -*- coding: utf-8 -*-
2# Part of Odoo. See LICENSE file for full copyright and licensing details.
3
4import json
5import logging
6from datetime import datetime, timedelta
7from collections import defaultdict
8
9from odoo import api, fields, models, _
10from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, float_compare, float_round
11from odoo.tools.float_utils import float_repr
12from odoo.tools.misc import format_date
13from odoo.exceptions import UserError
14
15
16_logger = logging.getLogger(__name__)
17
18
19class SaleOrder(models.Model):
20    _inherit = "sale.order"
21
22    @api.model
23    def _default_warehouse_id(self):
24        # !!! Any change to the default value may have to be repercuted
25        # on _init_column() below.
26        return self.env.user._get_default_warehouse_id()
27
28    incoterm = fields.Many2one(
29        'account.incoterms', 'Incoterm',
30        help="International Commercial Terms are a series of predefined commercial terms used in international transactions.")
31    picking_policy = fields.Selection([
32        ('direct', 'As soon as possible'),
33        ('one', 'When all products are ready')],
34        string='Shipping Policy', required=True, readonly=True, default='direct',
35        states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}
36        ,help="If you deliver all products at once, the delivery order will be scheduled based on the greatest "
37        "product lead time. Otherwise, it will be based on the shortest.")
38    warehouse_id = fields.Many2one(
39        'stock.warehouse', string='Warehouse',
40        required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
41        default=_default_warehouse_id, check_company=True)
42    picking_ids = fields.One2many('stock.picking', 'sale_id', string='Transfers')
43    delivery_count = fields.Integer(string='Delivery Orders', compute='_compute_picking_ids')
44    procurement_group_id = fields.Many2one('procurement.group', 'Procurement Group', copy=False)
45    effective_date = fields.Date("Effective Date", compute='_compute_effective_date', store=True, help="Completion date of the first delivery order.")
46    expected_date = fields.Datetime( help="Delivery date you can promise to the customer, computed from the minimum lead time of "
47                                          "the order lines in case of Service products. In case of shipping, the shipping policy of "
48                                          "the order will be taken into account to either use the minimum or maximum lead time of "
49                                          "the order lines.")
50    json_popover = fields.Char('JSON data for the popover widget', compute='_compute_json_popover')
51    show_json_popover = fields.Boolean('Has late picking', compute='_compute_json_popover')
52
53    def _init_column(self, column_name):
54        """ Ensure the default warehouse_id is correctly assigned
55
56        At column initialization, the ir.model.fields for res.users.property_warehouse_id isn't created,
57        which means trying to read the property field to get the default value will crash.
58        We therefore enforce the default here, without going through
59        the default function on the warehouse_id field.
60        """
61        if column_name != "warehouse_id":
62            return super(SaleOrder, self)._init_column(column_name)
63        field = self._fields[column_name]
64        default = self.env['stock.warehouse'].search([('company_id', '=', self.env.company.id)], limit=1)
65        value = field.convert_to_write(default, self)
66        value = field.convert_to_column(value, self)
67        if value is not None:
68            _logger.debug("Table '%s': setting default value of new column %s to %r",
69                self._table, column_name, value)
70            query = 'UPDATE "%s" SET "%s"=%s WHERE "%s" IS NULL' % (
71                self._table, column_name, field.column_format, column_name)
72            self._cr.execute(query, (value,))
73
74    @api.depends('picking_ids.date_done')
75    def _compute_effective_date(self):
76        for order in self:
77            pickings = order.picking_ids.filtered(lambda x: x.state == 'done' and x.location_dest_id.usage == 'customer')
78            dates_list = [date for date in pickings.mapped('date_done') if date]
79            order.effective_date = fields.Date.context_today(order, min(dates_list)) if dates_list else False
80
81    @api.depends('picking_policy')
82    def _compute_expected_date(self):
83        super(SaleOrder, self)._compute_expected_date()
84        for order in self:
85            dates_list = []
86            for line in order.order_line.filtered(lambda x: x.state != 'cancel' and not x._is_delivery() and not x.display_type):
87                dt = line._expected_date()
88                dates_list.append(dt)
89            if dates_list:
90                expected_date = min(dates_list) if order.picking_policy == 'direct' else max(dates_list)
91                order.expected_date = fields.Datetime.to_string(expected_date)
92
93    @api.model
94    def create(self, vals):
95        if 'warehouse_id' not in vals and 'company_id' in vals:
96            user = self.env['res.users'].browse(vals.get('user_id', False))
97            vals['warehouse_id'] = user.with_company(vals.get('company_id'))._get_default_warehouse_id().id
98        return super().create(vals)
99
100    def write(self, values):
101        if values.get('order_line') and self.state == 'sale':
102            for order in self:
103                pre_order_line_qty = {order_line: order_line.product_uom_qty for order_line in order.mapped('order_line') if not order_line.is_expense}
104
105        if values.get('partner_shipping_id'):
106            new_partner = self.env['res.partner'].browse(values.get('partner_shipping_id'))
107            for record in self:
108                picking = record.mapped('picking_ids').filtered(lambda x: x.state not in ('done', 'cancel'))
109                addresses = (record.partner_shipping_id.display_name, new_partner.display_name)
110                message = _("""The delivery address has been changed on the Sales Order<br/>
111                        From <strong>"%s"</strong> To <strong>"%s"</strong>,
112                        You should probably update the partner on this document.""") % addresses
113                picking.activity_schedule('mail.mail_activity_data_warning', note=message, user_id=self.env.user.id)
114
115        if values.get('commitment_date'):
116            # protagate commitment_date as the deadline of the related stock move.
117            # TODO: Log a note on each down document
118            self.order_line.move_ids.date_deadline = fields.Datetime.to_datetime(values.get('commitment_date'))
119
120        res = super(SaleOrder, self).write(values)
121        if values.get('order_line') and self.state == 'sale':
122            for order in self:
123                to_log = {}
124                for order_line in order.order_line:
125                    if float_compare(order_line.product_uom_qty, pre_order_line_qty.get(order_line, 0.0), order_line.product_uom.rounding) < 0:
126                        to_log[order_line] = (order_line.product_uom_qty, pre_order_line_qty.get(order_line, 0.0))
127                if to_log:
128                    documents = self.env['stock.picking']._log_activity_get_documents(to_log, 'move_ids', 'UP')
129                    documents = {k:v for k, v in documents.items() if k[0].state != 'cancel'}
130                    order._log_decrease_ordered_quantity(documents)
131        return res
132
133    def _compute_json_popover(self):
134        for order in self:
135            late_stock_picking = order.picking_ids.filtered(lambda p: p.delay_alert_date)
136            order.json_popover = json.dumps({
137                'popoverTemplate': 'sale_stock.DelayAlertWidget',
138                'late_elements': [{
139                        'id': late_move.id,
140                        'name': late_move.display_name,
141                        'model': 'stock.picking',
142                    } for late_move in late_stock_picking
143                ]
144            })
145            order.show_json_popover = bool(late_stock_picking)
146
147    def _action_confirm(self):
148        self.order_line._action_launch_stock_rule()
149        return super(SaleOrder, self)._action_confirm()
150
151    @api.depends('picking_ids')
152    def _compute_picking_ids(self):
153        for order in self:
154            order.delivery_count = len(order.picking_ids)
155
156    @api.onchange('company_id')
157    def _onchange_company_id(self):
158        if self.company_id:
159            warehouse_id = self.env['ir.default'].get_model_defaults('sale.order').get('warehouse_id')
160            self.warehouse_id = warehouse_id or self.user_id.with_company(self.company_id.id)._get_default_warehouse_id().id
161
162    @api.onchange('user_id')
163    def onchange_user_id(self):
164        super().onchange_user_id()
165        self.warehouse_id = self.user_id.with_company(self.company_id.id)._get_default_warehouse_id().id
166
167    @api.onchange('partner_shipping_id')
168    def _onchange_partner_shipping_id(self):
169        res = {}
170        pickings = self.picking_ids.filtered(
171            lambda p: p.state not in ['done', 'cancel'] and p.partner_id != self.partner_shipping_id
172        )
173        if pickings:
174            res['warning'] = {
175                'title': _('Warning!'),
176                'message': _(
177                    'Do not forget to change the partner on the following delivery orders: %s'
178                ) % (','.join(pickings.mapped('name')))
179            }
180        return res
181
182    def action_view_delivery(self):
183        '''
184        This function returns an action that display existing delivery orders
185        of given sales order ids. It can either be a in a list or in a form
186        view, if there is only one delivery order to show.
187        '''
188        action = self.env["ir.actions.actions"]._for_xml_id("stock.action_picking_tree_all")
189
190        pickings = self.mapped('picking_ids')
191        if len(pickings) > 1:
192            action['domain'] = [('id', 'in', pickings.ids)]
193        elif pickings:
194            form_view = [(self.env.ref('stock.view_picking_form').id, 'form')]
195            if 'views' in action:
196                action['views'] = form_view + [(state,view) for state,view in action['views'] if view != 'form']
197            else:
198                action['views'] = form_view
199            action['res_id'] = pickings.id
200        # Prepare the context.
201        picking_id = pickings.filtered(lambda l: l.picking_type_id.code == 'outgoing')
202        if picking_id:
203            picking_id = picking_id[0]
204        else:
205            picking_id = pickings[0]
206        action['context'] = dict(self._context, default_partner_id=self.partner_id.id, default_picking_type_id=picking_id.picking_type_id.id, default_origin=self.name, default_group_id=picking_id.group_id.id)
207        return action
208
209    def action_cancel(self):
210        documents = None
211        for sale_order in self:
212            if sale_order.state == 'sale' and sale_order.order_line:
213                sale_order_lines_quantities = {order_line: (order_line.product_uom_qty, 0) for order_line in sale_order.order_line}
214                documents = self.env['stock.picking']._log_activity_get_documents(sale_order_lines_quantities, 'move_ids', 'UP')
215        self.picking_ids.filtered(lambda p: p.state != 'done').action_cancel()
216        if documents:
217            filtered_documents = {}
218            for (parent, responsible), rendering_context in documents.items():
219                if parent._name == 'stock.picking':
220                    if parent.state == 'cancel':
221                        continue
222                filtered_documents[(parent, responsible)] = rendering_context
223            self._log_decrease_ordered_quantity(filtered_documents, cancel=True)
224        return super(SaleOrder, self).action_cancel()
225
226    def _prepare_invoice(self):
227        invoice_vals = super(SaleOrder, self)._prepare_invoice()
228        invoice_vals['invoice_incoterm_id'] = self.incoterm.id
229        return invoice_vals
230
231    @api.model
232    def _get_customer_lead(self, product_tmpl_id):
233        super(SaleOrder, self)._get_customer_lead(product_tmpl_id)
234        return product_tmpl_id.sale_delay
235
236    def _log_decrease_ordered_quantity(self, documents, cancel=False):
237
238        def _render_note_exception_quantity_so(rendering_context):
239            order_exceptions, visited_moves = rendering_context
240            visited_moves = list(visited_moves)
241            visited_moves = self.env[visited_moves[0]._name].concat(*visited_moves)
242            order_line_ids = self.env['sale.order.line'].browse([order_line.id for order in order_exceptions.values() for order_line in order[0]])
243            sale_order_ids = order_line_ids.mapped('order_id')
244            impacted_pickings = visited_moves.filtered(lambda m: m.state not in ('done', 'cancel')).mapped('picking_id')
245            values = {
246                'sale_order_ids': sale_order_ids,
247                'order_exceptions': order_exceptions.values(),
248                'impacted_pickings': impacted_pickings,
249                'cancel': cancel
250            }
251            return self.env.ref('sale_stock.exception_on_so')._render(values=values)
252
253        self.env['stock.picking']._log_activity(_render_note_exception_quantity_so, documents)
254
255    def _show_cancel_wizard(self):
256        res = super(SaleOrder, self)._show_cancel_wizard()
257        for order in self:
258            if any(picking.state == 'done' for picking in order.picking_ids) and not order._context.get('disable_cancel_warning'):
259                return True
260        return res
261
262class SaleOrderLine(models.Model):
263    _inherit = 'sale.order.line'
264
265    qty_delivered_method = fields.Selection(selection_add=[('stock_move', 'Stock Moves')])
266    product_packaging = fields.Many2one( 'product.packaging', string='Package', default=False, check_company=True)
267    route_id = fields.Many2one('stock.location.route', string='Route', domain=[('sale_selectable', '=', True)], ondelete='restrict', check_company=True)
268    move_ids = fields.One2many('stock.move', 'sale_line_id', string='Stock Moves')
269    product_type = fields.Selection(related='product_id.type')
270    virtual_available_at_date = fields.Float(compute='_compute_qty_at_date', digits='Product Unit of Measure')
271    scheduled_date = fields.Datetime(compute='_compute_qty_at_date')
272    forecast_expected_date = fields.Datetime(compute='_compute_qty_at_date')
273    free_qty_today = fields.Float(compute='_compute_qty_at_date', digits='Product Unit of Measure')
274    qty_available_today = fields.Float(compute='_compute_qty_at_date')
275    warehouse_id = fields.Many2one(related='order_id.warehouse_id')
276    qty_to_deliver = fields.Float(compute='_compute_qty_to_deliver', digits='Product Unit of Measure')
277    is_mto = fields.Boolean(compute='_compute_is_mto')
278    display_qty_widget = fields.Boolean(compute='_compute_qty_to_deliver')
279
280    @api.depends('product_type', 'product_uom_qty', 'qty_delivered', 'state', 'move_ids', 'product_uom')
281    def _compute_qty_to_deliver(self):
282        """Compute the visibility of the inventory widget."""
283        for line in self:
284            line.qty_to_deliver = line.product_uom_qty - line.qty_delivered
285            if line.state in ('draft', 'sent', 'sale') and line.product_type == 'product' and line.product_uom and line.qty_to_deliver > 0:
286                if line.state == 'sale' and not line.move_ids:
287                    line.display_qty_widget = False
288                else:
289                    line.display_qty_widget = True
290            else:
291                line.display_qty_widget = False
292
293    @api.depends(
294        'product_id', 'customer_lead', 'product_uom_qty', 'product_uom', 'order_id.commitment_date',
295        'move_ids', 'move_ids.forecast_expected_date', 'move_ids.forecast_availability')
296    def _compute_qty_at_date(self):
297        """ Compute the quantity forecasted of product at delivery date. There are
298        two cases:
299         1. The quotation has a commitment_date, we take it as delivery date
300         2. The quotation hasn't commitment_date, we compute the estimated delivery
301            date based on lead time"""
302        treated = self.browse()
303        # If the state is already in sale the picking is created and a simple forecasted quantity isn't enough
304        # Then used the forecasted data of the related stock.move
305        for line in self.filtered(lambda l: l.state == 'sale'):
306            if not line.display_qty_widget:
307                continue
308            moves = line.move_ids.filtered(lambda m: m.product_id == line.product_id)
309            line.forecast_expected_date = max(moves.filtered("forecast_expected_date").mapped("forecast_expected_date"), default=False)
310            line.qty_available_today = 0
311            line.free_qty_today = 0
312            for move in moves:
313                line.qty_available_today += move.product_uom._compute_quantity(move.reserved_availability, line.product_uom)
314                line.free_qty_today += move.product_id.uom_id._compute_quantity(move.forecast_availability, line.product_uom)
315            line.scheduled_date = line.order_id.commitment_date or line._expected_date()
316            line.virtual_available_at_date = False
317            treated |= line
318
319        qty_processed_per_product = defaultdict(lambda: 0)
320        grouped_lines = defaultdict(lambda: self.env['sale.order.line'])
321        # We first loop over the SO lines to group them by warehouse and schedule
322        # date in order to batch the read of the quantities computed field.
323        for line in self.filtered(lambda l: l.state in ('draft', 'sent')):
324            if not (line.product_id and line.display_qty_widget):
325                continue
326            grouped_lines[(line.warehouse_id.id, line.order_id.commitment_date or line._expected_date())] |= line
327
328        for (warehouse, scheduled_date), lines in grouped_lines.items():
329            product_qties = lines.mapped('product_id').with_context(to_date=scheduled_date, warehouse=warehouse).read([
330                'qty_available',
331                'free_qty',
332                'virtual_available',
333            ])
334            qties_per_product = {
335                product['id']: (product['qty_available'], product['free_qty'], product['virtual_available'])
336                for product in product_qties
337            }
338            for line in lines:
339                line.scheduled_date = scheduled_date
340                qty_available_today, free_qty_today, virtual_available_at_date = qties_per_product[line.product_id.id]
341                line.qty_available_today = qty_available_today - qty_processed_per_product[line.product_id.id]
342                line.free_qty_today = free_qty_today - qty_processed_per_product[line.product_id.id]
343                line.virtual_available_at_date = virtual_available_at_date - qty_processed_per_product[line.product_id.id]
344                line.forecast_expected_date = False
345                product_qty = line.product_uom_qty
346                if line.product_uom and line.product_id.uom_id and line.product_uom != line.product_id.uom_id:
347                    line.qty_available_today = line.product_id.uom_id._compute_quantity(line.qty_available_today, line.product_uom)
348                    line.free_qty_today = line.product_id.uom_id._compute_quantity(line.free_qty_today, line.product_uom)
349                    line.virtual_available_at_date = line.product_id.uom_id._compute_quantity(line.virtual_available_at_date, line.product_uom)
350                    product_qty = line.product_uom._compute_quantity(product_qty, line.product_id.uom_id)
351                qty_processed_per_product[line.product_id.id] += product_qty
352            treated |= lines
353        remaining = (self - treated)
354        remaining.virtual_available_at_date = False
355        remaining.scheduled_date = False
356        remaining.forecast_expected_date = False
357        remaining.free_qty_today = False
358        remaining.qty_available_today = False
359
360    @api.depends('product_id', 'route_id', 'order_id.warehouse_id', 'product_id.route_ids')
361    def _compute_is_mto(self):
362        """ Verify the route of the product based on the warehouse
363            set 'is_available' at True if the product availibility in stock does
364            not need to be verified, which is the case in MTO, Cross-Dock or Drop-Shipping
365        """
366        self.is_mto = False
367        for line in self:
368            if not line.display_qty_widget:
369                continue
370            product = line.product_id
371            product_routes = line.route_id or (product.route_ids + product.categ_id.total_route_ids)
372
373            # Check MTO
374            mto_route = line.order_id.warehouse_id.mto_pull_id.route_id
375            if not mto_route:
376                try:
377                    mto_route = self.env['stock.warehouse']._find_global_route('stock.route_warehouse0_mto', _('Make To Order'))
378                except UserError:
379                    # if route MTO not found in ir_model_data, we treat the product as in MTS
380                    pass
381
382            if mto_route and mto_route in product_routes:
383                line.is_mto = True
384            else:
385                line.is_mto = False
386
387    @api.depends('product_id')
388    def _compute_qty_delivered_method(self):
389        """ Stock module compute delivered qty for product [('type', 'in', ['consu', 'product'])]
390            For SO line coming from expense, no picking should be generate: we don't manage stock for
391            thoses lines, even if the product is a storable.
392        """
393        super(SaleOrderLine, self)._compute_qty_delivered_method()
394
395        for line in self:
396            if not line.is_expense and line.product_id.type in ['consu', 'product']:
397                line.qty_delivered_method = 'stock_move'
398
399    @api.depends('move_ids.state', 'move_ids.scrapped', 'move_ids.product_uom_qty', 'move_ids.product_uom')
400    def _compute_qty_delivered(self):
401        super(SaleOrderLine, self)._compute_qty_delivered()
402
403        for line in self:  # TODO: maybe one day, this should be done in SQL for performance sake
404            if line.qty_delivered_method == 'stock_move':
405                qty = 0.0
406                outgoing_moves, incoming_moves = line._get_outgoing_incoming_moves()
407                for move in outgoing_moves:
408                    if move.state != 'done':
409                        continue
410                    qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP')
411                for move in incoming_moves:
412                    if move.state != 'done':
413                        continue
414                    qty -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP')
415                line.qty_delivered = qty
416
417    @api.model_create_multi
418    def create(self, vals_list):
419        lines = super(SaleOrderLine, self).create(vals_list)
420        lines.filtered(lambda line: line.state == 'sale')._action_launch_stock_rule()
421        return lines
422
423    def write(self, values):
424        lines = self.env['sale.order.line']
425        if 'product_uom_qty' in values:
426            precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
427            lines = self.filtered(
428                lambda r: r.state == 'sale' and not r.is_expense and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == -1)
429        previous_product_uom_qty = {line.id: line.product_uom_qty for line in lines}
430        res = super(SaleOrderLine, self).write(values)
431        if lines:
432            lines._action_launch_stock_rule(previous_product_uom_qty)
433        if 'customer_lead' in values and self.state == 'sale' and not self.order_id.commitment_date:
434            # Propagate deadline on related stock move
435            self.move_ids.date_deadline = self.order_id.date_order + timedelta(days=self.customer_lead or 0.0)
436        return res
437
438    @api.depends('order_id.state')
439    def _compute_invoice_status(self):
440        def check_moves_state(moves):
441            # All moves states are either 'done' or 'cancel', and there is at least one 'done'
442            at_least_one_done = False
443            for move in moves:
444                if move.state not in ['done', 'cancel']:
445                    return False
446                at_least_one_done = at_least_one_done or move.state == 'done'
447            return at_least_one_done
448        super(SaleOrderLine, self)._compute_invoice_status()
449        for line in self:
450            # We handle the following specific situation: a physical product is partially delivered,
451            # but we would like to set its invoice status to 'Fully Invoiced'. The use case is for
452            # products sold by weight, where the delivered quantity rarely matches exactly the
453            # quantity ordered.
454            if line.order_id.state == 'done'\
455                    and line.invoice_status == 'no'\
456                    and line.product_id.type in ['consu', 'product']\
457                    and line.product_id.invoice_policy == 'delivery'\
458                    and line.move_ids \
459                    and check_moves_state(line.move_ids):
460                line.invoice_status = 'invoiced'
461
462    @api.depends('move_ids')
463    def _compute_product_updatable(self):
464        for line in self:
465            if not line.move_ids.filtered(lambda m: m.state != 'cancel'):
466                super(SaleOrderLine, line)._compute_product_updatable()
467            else:
468                line.product_updatable = False
469
470    @api.onchange('product_id')
471    def _onchange_product_id_set_customer_lead(self):
472        self.customer_lead = self.product_id.sale_delay
473
474    @api.onchange('product_packaging')
475    def _onchange_product_packaging(self):
476        if self.product_packaging:
477            return self._check_package()
478
479    @api.onchange('product_uom_qty')
480    def _onchange_product_uom_qty(self):
481        # When modifying a one2many, _origin doesn't guarantee that its values will be the ones
482        # in database. Hence, we need to explicitly read them from there.
483        if self._origin:
484            product_uom_qty_origin = self._origin.read(["product_uom_qty"])[0]["product_uom_qty"]
485        else:
486            product_uom_qty_origin = 0
487
488        if self.state == 'sale' and self.product_id.type in ['product', 'consu'] and self.product_uom_qty < product_uom_qty_origin:
489            # Do not display this warning if the new quantity is below the delivered
490            # one; the `write` will raise an `UserError` anyway.
491            if self.product_uom_qty < self.qty_delivered:
492                return {}
493            warning_mess = {
494                'title': _('Ordered quantity decreased!'),
495                'message' : _('You are decreasing the ordered quantity! Do not forget to manually update the delivery order if needed.'),
496            }
497            return {'warning': warning_mess}
498        return {}
499
500    def _prepare_procurement_values(self, group_id=False):
501        """ Prepare specific key for moves or other components that will be created from a stock rule
502        comming from a sale order line. This method could be override in order to add other custom key that could
503        be used in move/po creation.
504        """
505        values = super(SaleOrderLine, self)._prepare_procurement_values(group_id)
506        self.ensure_one()
507        # Use the delivery date if there is else use date_order and lead time
508        date_deadline = self.order_id.commitment_date or (self.order_id.date_order + timedelta(days=self.customer_lead or 0.0))
509        date_planned = date_deadline - timedelta(days=self.order_id.company_id.security_lead)
510        values.update({
511            'group_id': group_id,
512            'sale_line_id': self.id,
513            'date_planned': date_planned,
514            'date_deadline': date_deadline,
515            'route_ids': self.route_id,
516            'warehouse_id': self.order_id.warehouse_id or False,
517            'partner_id': self.order_id.partner_shipping_id.id,
518            'product_description_variants': self._get_sale_order_line_multiline_description_variants(),
519            'company_id': self.order_id.company_id,
520        })
521        return values
522
523    def _get_qty_procurement(self, previous_product_uom_qty=False):
524        self.ensure_one()
525        qty = 0.0
526        outgoing_moves, incoming_moves = self._get_outgoing_incoming_moves()
527        for move in outgoing_moves:
528            qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
529        for move in incoming_moves:
530            qty -= move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
531        return qty
532
533    def _get_outgoing_incoming_moves(self):
534        outgoing_moves = self.env['stock.move']
535        incoming_moves = self.env['stock.move']
536
537        for move in self.move_ids.filtered(lambda r: r.state != 'cancel' and not r.scrapped and self.product_id == r.product_id):
538            if move.location_dest_id.usage == "customer":
539                if not move.origin_returned_move_id or (move.origin_returned_move_id and move.to_refund):
540                    outgoing_moves |= move
541            elif move.location_dest_id.usage != "customer" and move.to_refund:
542                incoming_moves |= move
543
544        return outgoing_moves, incoming_moves
545
546    def _get_procurement_group(self):
547        return self.order_id.procurement_group_id
548
549    def _prepare_procurement_group_vals(self):
550        return {
551            'name': self.order_id.name,
552            'move_type': self.order_id.picking_policy,
553            'sale_id': self.order_id.id,
554            'partner_id': self.order_id.partner_shipping_id.id,
555        }
556
557    def _action_launch_stock_rule(self, previous_product_uom_qty=False):
558        """
559        Launch procurement group run method with required/custom fields genrated by a
560        sale order line. procurement group will launch '_run_pull', '_run_buy' or '_run_manufacture'
561        depending on the sale order line product rule.
562        """
563        precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
564        procurements = []
565        for line in self:
566            line = line.with_company(line.company_id)
567            if line.state != 'sale' or not line.product_id.type in ('consu','product'):
568                continue
569            qty = line._get_qty_procurement(previous_product_uom_qty)
570            if float_compare(qty, line.product_uom_qty, precision_digits=precision) >= 0:
571                continue
572
573            group_id = line._get_procurement_group()
574            if not group_id:
575                group_id = self.env['procurement.group'].create(line._prepare_procurement_group_vals())
576                line.order_id.procurement_group_id = group_id
577            else:
578                # In case the procurement group is already created and the order was
579                # cancelled, we need to update certain values of the group.
580                updated_vals = {}
581                if group_id.partner_id != line.order_id.partner_shipping_id:
582                    updated_vals.update({'partner_id': line.order_id.partner_shipping_id.id})
583                if group_id.move_type != line.order_id.picking_policy:
584                    updated_vals.update({'move_type': line.order_id.picking_policy})
585                if updated_vals:
586                    group_id.write(updated_vals)
587
588            values = line._prepare_procurement_values(group_id=group_id)
589            product_qty = line.product_uom_qty - qty
590
591            line_uom = line.product_uom
592            quant_uom = line.product_id.uom_id
593            product_qty, procurement_uom = line_uom._adjust_uom_quantities(product_qty, quant_uom)
594            procurements.append(self.env['procurement.group'].Procurement(
595                line.product_id, product_qty, procurement_uom,
596                line.order_id.partner_shipping_id.property_stock_customer,
597                line.name, line.order_id.name, line.order_id.company_id, values))
598        if procurements:
599            self.env['procurement.group'].run(procurements)
600        return True
601
602    def _check_package(self):
603        default_uom = self.product_id.uom_id
604        pack = self.product_packaging
605        qty = self.product_uom_qty
606        q = default_uom._compute_quantity(pack.qty, self.product_uom)
607        # We do not use the modulo operator to check if qty is a mltiple of q. Indeed the quantity
608        # per package might be a float, leading to incorrect results. For example:
609        # 8 % 1.6 = 1.5999999999999996
610        # 5.4 % 1.8 = 2.220446049250313e-16
611        if (
612            qty
613            and q
614            and float_compare(
615                qty / q, float_round(qty / q, precision_rounding=1.0), precision_rounding=0.001
616            )
617            != 0
618        ):
619            newqty = qty - (qty % q) + q
620            return {
621                'warning': {
622                    'title': _('Warning'),
623                    'message': _(
624                        "This product is packaged by %(pack_size).2f %(pack_name)s. You should sell %(quantity).2f %(unit)s.",
625                        pack_size=pack.qty,
626                        pack_name=default_uom.name,
627                        quantity=newqty,
628                        unit=self.product_uom.name
629                    ),
630                },
631            }
632        return {}
633
634    def _update_line_quantity(self, values):
635        precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
636        line_products = self.filtered(lambda l: l.product_id.type in ['product', 'consu'])
637        if line_products.mapped('qty_delivered') and float_compare(values['product_uom_qty'], max(line_products.mapped('qty_delivered')), precision_digits=precision) == -1:
638            raise UserError(_('You cannot decrease the ordered quantity below the delivered quantity.\n'
639                              'Create a return first.'))
640        super(SaleOrderLine, self)._update_line_quantity(values)
641