1# -*- coding: utf-8 -*-
2# Part of Odoo. See LICENSE file for full copyright and licensing details.
3
4import json
5from collections import defaultdict
6from datetime import datetime
7from itertools import groupby
8from operator import itemgetter
9from re import findall as regex_findall
10from re import split as regex_split
11
12from dateutil import relativedelta
13
14from odoo import SUPERUSER_ID, _, api, fields, models
15from odoo.exceptions import UserError
16from odoo.osv import expression
17from odoo.tools.float_utils import float_compare, float_is_zero, float_repr, float_round
18from odoo.tools.misc import format_date, OrderedSet
19
20PROCUREMENT_PRIORITIES = [('0', 'Normal'), ('1', 'Urgent')]
21
22
23class StockMove(models.Model):
24    _name = "stock.move"
25    _description = "Stock Move"
26    _order = 'sequence, id'
27
28    def _default_group_id(self):
29        if self.env.context.get('default_picking_id'):
30            return self.env['stock.picking'].browse(self.env.context['default_picking_id']).group_id.id
31        return False
32
33    name = fields.Char('Description', index=True, required=True)
34    sequence = fields.Integer('Sequence', default=10)
35    priority = fields.Selection(
36        PROCUREMENT_PRIORITIES, 'Priority', default='0',
37        compute="_compute_priority", store=True, index=True)
38    create_date = fields.Datetime('Creation Date', index=True, readonly=True)
39    date = fields.Datetime(
40        'Date Scheduled', default=fields.Datetime.now, index=True, required=True,
41        help="Scheduled date until move is done, then date of actual move processing")
42    date_deadline = fields.Datetime(
43        "Deadline", readonly=True,
44        help="Date Promise to the customer on the top level document (SO/PO)")
45    company_id = fields.Many2one(
46        'res.company', 'Company',
47        default=lambda self: self.env.company,
48        index=True, required=True)
49    product_id = fields.Many2one(
50        'product.product', 'Product',
51        check_company=True,
52        domain="[('type', 'in', ['product', 'consu']), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", index=True, required=True,
53        states={'done': [('readonly', True)]})
54    description_picking = fields.Text('Description of Picking')
55    product_qty = fields.Float(
56        'Real Quantity', compute='_compute_product_qty', inverse='_set_product_qty',
57        digits=0, store=True, compute_sudo=True,
58        help='Quantity in the default UoM of the product')
59    product_uom_qty = fields.Float(
60        'Demand',
61        digits='Product Unit of Measure',
62        default=0.0, required=True, states={'done': [('readonly', True)]},
63        help="This is the quantity of products from an inventory "
64             "point of view. For moves in the state 'done', this is the "
65             "quantity of products that were actually moved. For other "
66             "moves, this is the quantity of product that is planned to "
67             "be moved. Lowering this quantity does not generate a "
68             "backorder. Changing this quantity on assigned moves affects "
69             "the product reservation, and should be done with care.")
70    product_uom = fields.Many2one('uom.uom', 'Unit of Measure', required=True, domain="[('category_id', '=', product_uom_category_id)]")
71    product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id')
72    # TDE FIXME: make it stored, otherwise group will not work
73    product_tmpl_id = fields.Many2one(
74        'product.template', 'Product Template',
75        related='product_id.product_tmpl_id', readonly=False,
76        help="Technical: used in views")
77    location_id = fields.Many2one(
78        'stock.location', 'Source Location',
79        auto_join=True, index=True, required=True,
80        check_company=True,
81        help="Sets a location if you produce at a fixed location. This can be a partner location if you subcontract the manufacturing operations.")
82    location_dest_id = fields.Many2one(
83        'stock.location', 'Destination Location',
84        auto_join=True, index=True, required=True,
85        check_company=True,
86        help="Location where the system will stock the finished products.")
87    partner_id = fields.Many2one(
88        'res.partner', 'Destination Address ',
89        states={'done': [('readonly', True)]},
90        help="Optional address where goods are to be delivered, specifically used for allotment")
91    move_dest_ids = fields.Many2many(
92        'stock.move', 'stock_move_move_rel', 'move_orig_id', 'move_dest_id', 'Destination Moves',
93        copy=False,
94        help="Optional: next stock move when chaining them")
95    move_orig_ids = fields.Many2many(
96        'stock.move', 'stock_move_move_rel', 'move_dest_id', 'move_orig_id', 'Original Move',
97        copy=False,
98        help="Optional: previous stock move when chaining them")
99    picking_id = fields.Many2one('stock.picking', 'Transfer', index=True, states={'done': [('readonly', True)]}, check_company=True)
100    picking_partner_id = fields.Many2one('res.partner', 'Transfer Destination Address', related='picking_id.partner_id', readonly=False)
101    note = fields.Text('Notes')
102    state = fields.Selection([
103        ('draft', 'New'), ('cancel', 'Cancelled'),
104        ('waiting', 'Waiting Another Move'),
105        ('confirmed', 'Waiting Availability'),
106        ('partially_available', 'Partially Available'),
107        ('assigned', 'Available'),
108        ('done', 'Done')], string='Status',
109        copy=False, default='draft', index=True, readonly=True,
110        help="* New: When the stock move is created and not yet confirmed.\n"
111             "* Waiting Another Move: This state can be seen when a move is waiting for another one, for example in a chained flow.\n"
112             "* Waiting Availability: This state is reached when the procurement resolution is not straight forward. It may need the scheduler to run, a component to be manufactured...\n"
113             "* Available: When products are reserved, it is set to \'Available\'.\n"
114             "* Done: When the shipment is processed, the state is \'Done\'.")
115    price_unit = fields.Float(
116        'Unit Price', help="Technical field used to record the product cost set by the user during a picking confirmation (when costing "
117                           "method used is 'average price' or 'real'). Value given in company currency and in product uom.", copy=False)  # as it's a technical field, we intentionally don't provide the digits attribute
118    backorder_id = fields.Many2one('stock.picking', 'Back Order of', related='picking_id.backorder_id', index=True, readonly=False)
119    origin = fields.Char("Source Document")
120    procure_method = fields.Selection([
121        ('make_to_stock', 'Default: Take From Stock'),
122        ('make_to_order', 'Advanced: Apply Procurement Rules')], string='Supply Method',
123        default='make_to_stock', required=True,
124        help="By default, the system will take from the stock in the source location and passively wait for availability. "
125             "The other possibility allows you to directly create a procurement on the source location (and thus ignore "
126             "its current stock) to gather products. If we want to chain moves and have this one to wait for the previous, "
127             "this second option should be chosen.")
128    scrapped = fields.Boolean('Scrapped', related='location_dest_id.scrap_location', readonly=True, store=True)
129    scrap_ids = fields.One2many('stock.scrap', 'move_id')
130    group_id = fields.Many2one('procurement.group', 'Procurement Group', default=_default_group_id)
131    rule_id = fields.Many2one(
132        'stock.rule', 'Stock Rule', ondelete='restrict', help='The stock rule that created this stock move',
133        check_company=True)
134    propagate_cancel = fields.Boolean(
135        'Propagate cancel and split', default=True,
136        help='If checked, when this move is cancelled, cancel the linked move too')
137    delay_alert_date = fields.Datetime('Delay Alert Date', help='Process at this date to be on time', compute="_compute_delay_alert_date", store=True)
138    picking_type_id = fields.Many2one('stock.picking.type', 'Operation Type', check_company=True)
139    inventory_id = fields.Many2one('stock.inventory', 'Inventory', check_company=True)
140    move_line_ids = fields.One2many('stock.move.line', 'move_id')
141    move_line_nosuggest_ids = fields.One2many('stock.move.line', 'move_id', domain=['|', ('product_qty', '=', 0.0), ('qty_done', '!=', 0.0)])
142    origin_returned_move_id = fields.Many2one(
143        'stock.move', 'Origin return move', copy=False, index=True,
144        help='Move that created the return move', check_company=True)
145    returned_move_ids = fields.One2many('stock.move', 'origin_returned_move_id', 'All returned moves', help='Optional: all returned moves created from this move')
146    reserved_availability = fields.Float(
147        'Quantity Reserved', compute='_compute_reserved_availability',
148        digits='Product Unit of Measure',
149        readonly=True, help='Quantity that has already been reserved for this move')
150    availability = fields.Float(
151        'Forecasted Quantity', compute='_compute_product_availability',
152        readonly=True, help='Quantity in stock that can still be reserved for this move')
153    restrict_partner_id = fields.Many2one(
154        'res.partner', 'Owner ', help="Technical field used to depict a restriction on the ownership of quants to consider when marking this move as 'done'",
155        check_company=True)
156    route_ids = fields.Many2many(
157        'stock.location.route', 'stock_location_route_move', 'move_id', 'route_id', 'Destination route', help="Preferred route",
158        check_company=True)
159    warehouse_id = fields.Many2one('stock.warehouse', 'Warehouse', help="Technical field depicting the warehouse to consider for the route selection on the next procurement (if any).")
160    has_tracking = fields.Selection(related='product_id.tracking', string='Product with Tracking')
161    quantity_done = fields.Float('Quantity Done', compute='_quantity_done_compute', digits='Product Unit of Measure', inverse='_quantity_done_set')
162    show_operations = fields.Boolean(related='picking_id.picking_type_id.show_operations', readonly=False)
163    show_details_visible = fields.Boolean('Details Visible', compute='_compute_show_details_visible')
164    show_reserved_availability = fields.Boolean('From Supplier', compute='_compute_show_reserved_availability')
165    picking_code = fields.Selection(related='picking_id.picking_type_id.code', readonly=True)
166    product_type = fields.Selection(related='product_id.type', readonly=True)
167    additional = fields.Boolean("Whether the move was added after the picking's confirmation", default=False)
168    is_locked = fields.Boolean(compute='_compute_is_locked', readonly=True)
169    is_initial_demand_editable = fields.Boolean('Is initial demand editable', compute='_compute_is_initial_demand_editable')
170    is_quantity_done_editable = fields.Boolean('Is quantity done editable', compute='_compute_is_quantity_done_editable')
171    reference = fields.Char(compute='_compute_reference', string="Reference", store=True)
172    has_move_lines = fields.Boolean(compute='_compute_has_move_lines')
173    package_level_id = fields.Many2one('stock.package_level', 'Package Level', check_company=True, copy=False)
174    picking_type_entire_packs = fields.Boolean(related='picking_type_id.show_entire_packs', readonly=True)
175    display_assign_serial = fields.Boolean(compute='_compute_display_assign_serial')
176    next_serial = fields.Char('First SN')
177    next_serial_count = fields.Integer('Number of SN')
178    orderpoint_id = fields.Many2one('stock.warehouse.orderpoint', 'Original Reordering Rule', check_company=True)
179    forecast_availability = fields.Float('Forecast Availability', compute='_compute_forecast_information', digits='Product Unit of Measure', compute_sudo=True)
180    forecast_expected_date = fields.Datetime('Forecasted Expected date', compute='_compute_forecast_information', compute_sudo=True)
181    lot_ids = fields.Many2many('stock.production.lot', compute='_compute_lot_ids', inverse='_set_lot_ids', string='Serial Numbers', readonly=False)
182
183    @api.onchange('product_id', 'picking_type_id')
184    def onchange_product(self):
185        if self.product_id:
186            product = self.product_id.with_context(lang=self._get_lang())
187            self.description_picking = product._get_description(self.picking_type_id)
188
189    @api.depends('has_tracking', 'picking_type_id.use_create_lots', 'picking_type_id.use_existing_lots', 'state')
190    def _compute_display_assign_serial(self):
191        for move in self:
192            move.display_assign_serial = (
193                move.has_tracking == 'serial' and
194                move.state in ('partially_available', 'assigned', 'confirmed') and
195                move.picking_type_id.use_create_lots and
196                not move.picking_type_id.use_existing_lots
197                and not move.origin_returned_move_id.id
198            )
199
200    @api.depends('picking_id.priority')
201    def _compute_priority(self):
202        for move in self:
203            move.priority = move.picking_id.priority or '0'
204
205    @api.depends('picking_id.is_locked')
206    def _compute_is_locked(self):
207        for move in self:
208            if move.picking_id:
209                move.is_locked = move.picking_id.is_locked
210            else:
211                move.is_locked = False
212
213    @api.depends('product_id', 'has_tracking', 'move_line_ids')
214    def _compute_show_details_visible(self):
215        """ According to this field, the button that calls `action_show_details` will be displayed
216        to work on a move from its picking form view, or not.
217        """
218        has_package = self.user_has_groups('stock.group_tracking_lot')
219        multi_locations_enabled = self.user_has_groups('stock.group_stock_multi_locations')
220        consignment_enabled = self.user_has_groups('stock.group_tracking_owner')
221
222        show_details_visible = multi_locations_enabled or has_package
223
224        for move in self:
225            if not move.product_id:
226                move.show_details_visible = False
227            elif len(move.move_line_ids) > 1:
228                move.show_details_visible = True
229            else:
230                move.show_details_visible = (((consignment_enabled and move.picking_id.picking_type_id.code != 'incoming') or
231                                             show_details_visible or move.has_tracking != 'none') and
232                                             move._show_details_in_draft() and
233                                             move.picking_id.picking_type_id.show_operations is False)
234
235    def _compute_show_reserved_availability(self):
236        """ This field is only of use in an attrs in the picking view, in order to hide the
237        "available" column if the move is coming from a supplier.
238        """
239        for move in self:
240            move.show_reserved_availability = not move.location_id.usage == 'supplier'
241
242    @api.depends('state', 'picking_id')
243    def _compute_is_initial_demand_editable(self):
244        for move in self:
245            if not move.picking_id.immediate_transfer and move.state == 'draft':
246                move.is_initial_demand_editable = True
247            elif not move.picking_id.is_locked and move.state != 'done' and move.picking_id:
248                move.is_initial_demand_editable = True
249            else:
250                move.is_initial_demand_editable = False
251
252    @api.depends('state', 'picking_id', 'product_id')
253    def _compute_is_quantity_done_editable(self):
254        for move in self:
255            if not move.product_id:
256                move.is_quantity_done_editable = False
257            elif not move.picking_id.immediate_transfer and move.picking_id.state == 'draft':
258                move.is_quantity_done_editable = False
259            elif move.picking_id.is_locked and move.state in ('done', 'cancel'):
260                move.is_quantity_done_editable = False
261            elif move.show_details_visible:
262                move.is_quantity_done_editable = False
263            elif move.show_operations:
264                move.is_quantity_done_editable = False
265            else:
266                move.is_quantity_done_editable = True
267
268    @api.depends('picking_id', 'name')
269    def _compute_reference(self):
270        for move in self:
271            move.reference = move.picking_id.name if move.picking_id else move.name
272
273    @api.depends('move_line_ids')
274    def _compute_has_move_lines(self):
275        for move in self:
276            move.has_move_lines = bool(move.move_line_ids)
277
278    @api.depends('product_id', 'product_uom', 'product_uom_qty')
279    def _compute_product_qty(self):
280        # DLE FIXME: `stock/tests/test_move2.py`
281        # `product_qty` is a STORED compute field which depends on the context :/
282        # I asked SLE to change this, task: 2041971
283        # In the mean time I cheat and force the rouding to half-up, it seems it works for all tests.
284        rounding_method = 'HALF-UP'
285        for move in self:
286            move.product_qty = move.product_uom._compute_quantity(
287                move.product_uom_qty, move.product_id.uom_id, rounding_method=rounding_method)
288
289    def _get_move_lines(self):
290        """ This will return the move lines to consider when applying _quantity_done_compute on a stock.move.
291        In some context, such as MRP, it is necessary to compute quantity_done on filtered sock.move.line."""
292        self.ensure_one()
293        if self.picking_type_id.show_reserved is False:
294            return self.move_line_nosuggest_ids
295        return self.move_line_ids
296
297    @api.depends('move_orig_ids.date', 'move_orig_ids.state', 'state', 'date')
298    def _compute_delay_alert_date(self):
299        for move in self:
300            if move.state in ('done', 'cancel'):
301                move.delay_alert_date = False
302                continue
303            prev_moves = move.move_orig_ids.filtered(lambda m: m.state not in ('done', 'cancel') and m.date)
304            prev_max_date = max(prev_moves.mapped("date"), default=False)
305            if prev_max_date and prev_max_date > move.date:
306                move.delay_alert_date = prev_max_date
307            else:
308                move.delay_alert_date = False
309
310    @api.depends('move_line_ids.qty_done', 'move_line_ids.product_uom_id', 'move_line_nosuggest_ids.qty_done', 'picking_type_id')
311    def _quantity_done_compute(self):
312        """ This field represents the sum of the move lines `qty_done`. It allows the user to know
313        if there is still work to do.
314
315        We take care of rounding this value at the general decimal precision and not the rounding
316        of the move's UOM to make sure this value is really close to the real sum, because this
317        field will be used in `_action_done` in order to know if the move will need a backorder or
318        an extra move.
319        """
320        if not any(self._ids):
321            # onchange
322            for move in self:
323                quantity_done = 0
324                for move_line in move._get_move_lines():
325                    quantity_done += move_line.product_uom_id._compute_quantity(
326                        move_line.qty_done, move.product_uom, round=False)
327                move.quantity_done = quantity_done
328        else:
329            # compute
330            move_lines_ids = set()
331            for move in self:
332                move_lines_ids |= set(move._get_move_lines().ids)
333
334            data = self.env['stock.move.line'].read_group(
335                [('id', 'in', list(move_lines_ids))],
336                ['move_id', 'product_uom_id', 'qty_done'], ['move_id', 'product_uom_id'],
337                lazy=False
338            )
339
340            rec = defaultdict(list)
341            for d in data:
342                rec[d['move_id'][0]] += [(d['product_uom_id'][0], d['qty_done'])]
343
344            for move in self:
345                uom = move.product_uom
346                move.quantity_done = sum(
347                    self.env['uom.uom'].browse(line_uom_id)._compute_quantity(qty, uom, round=False)
348                     for line_uom_id, qty in rec.get(move.ids[0] if move.ids else move.id, [])
349                )
350
351    def _quantity_done_set(self):
352        quantity_done = self[0].quantity_done  # any call to create will invalidate `move.quantity_done`
353        for move in self:
354            move_lines = move._get_move_lines()
355            if not move_lines:
356                if quantity_done:
357                    # do not impact reservation here
358                    move_line = self.env['stock.move.line'].create(dict(move._prepare_move_line_vals(), qty_done=quantity_done))
359                    move.write({'move_line_ids': [(4, move_line.id)]})
360            elif len(move_lines) == 1:
361                move_lines[0].qty_done = quantity_done
362            else:
363                # Bypass the error if we're trying to write the same value.
364                ml_quantity_done = 0
365                for move_line in move_lines:
366                    ml_quantity_done += move_line.product_uom_id._compute_quantity(move_line.qty_done, move.product_uom, round=False)
367                if float_compare(quantity_done, ml_quantity_done, precision_rounding=move.product_uom.rounding) != 0:
368                    raise UserError(_("Cannot set the done quantity from this stock move, work directly with the move lines."))
369
370    def _set_product_qty(self):
371        """ The meaning of product_qty field changed lately and is now a functional field computing the quantity
372        in the default product UoM. This code has been added to raise an error if a write is made given a value
373        for `product_qty`, where the same write should set the `product_uom_qty` field instead, in order to
374        detect errors. """
375        raise UserError(_('The requested operation cannot be processed because of a programming error setting the `product_qty` field instead of the `product_uom_qty`.'))
376
377    @api.depends('move_line_ids.product_qty')
378    def _compute_reserved_availability(self):
379        """ Fill the `availability` field on a stock move, which is the actual reserved quantity
380        and is represented by the aggregated `product_qty` on the linked move lines. If the move
381        is force assigned, the value will be 0.
382        """
383        if not any(self._ids):
384            # onchange
385            for move in self:
386                reserved_availability = sum(move.move_line_ids.mapped('product_qty'))
387                move.reserved_availability = move.product_id.uom_id._compute_quantity(
388                    reserved_availability, move.product_uom, rounding_method='HALF-UP')
389        else:
390            # compute
391            result = {data['move_id'][0]: data['product_qty'] for data in
392                      self.env['stock.move.line'].read_group([('move_id', 'in', self.ids)], ['move_id', 'product_qty'], ['move_id'])}
393            for move in self:
394                move.reserved_availability = move.product_id.uom_id._compute_quantity(
395                    result.get(move.id, 0.0), move.product_uom, rounding_method='HALF-UP')
396
397    @api.depends('state', 'product_id', 'product_qty', 'location_id')
398    def _compute_product_availability(self):
399        """ Fill the `availability` field on a stock move, which is the quantity to potentially
400        reserve. When the move is done, `availability` is set to the quantity the move did actually
401        move.
402        """
403        for move in self:
404            if move.state == 'done':
405                move.availability = move.product_qty
406            else:
407                total_availability = self.env['stock.quant']._get_available_quantity(move.product_id, move.location_id) if move.product_id else 0.0
408                move.availability = min(move.product_qty, total_availability)
409
410    @api.depends('product_id', 'picking_type_id', 'picking_id', 'reserved_availability', 'priority', 'state', 'product_uom_qty', 'location_id')
411    def _compute_forecast_information(self):
412        """ Compute forecasted information of the related product by warehouse."""
413        self.forecast_availability = False
414        self.forecast_expected_date = False
415
416        not_product_moves = self.filtered(lambda move: move.product_id.type != 'product')
417        for move in not_product_moves:
418            move.forecast_availability = move.product_qty
419
420        product_moves = (self - not_product_moves)
421        warehouse_by_location = {loc: loc.get_warehouse() for loc in product_moves.location_id}
422
423        outgoing_unreserved_moves_per_warehouse = defaultdict(lambda: self.env['stock.move'])
424        for move in product_moves:
425            picking_type = move.picking_type_id or move.picking_id.picking_type_id
426            is_unreserved = move.state in ('waiting', 'confirmed', 'partially_available')
427            if picking_type.code in self._consuming_picking_types() and is_unreserved:
428                outgoing_unreserved_moves_per_warehouse[warehouse_by_location[move.location_id]] |= move
429            elif picking_type.code in self._consuming_picking_types():
430                move.forecast_availability = move.product_uom._compute_quantity(
431                    move.reserved_availability, move.product_id.uom_id, rounding_method='HALF-UP')
432
433        for warehouse, moves in outgoing_unreserved_moves_per_warehouse.items():
434            if not warehouse:  # No prediction possible if no warehouse.
435                continue
436            product_variant_ids = moves.product_id.ids
437            wh_location_ids = [loc['id'] for loc in self.env['stock.location'].search_read(
438                [('id', 'child_of', warehouse.view_location_id.id)],
439                ['id'],
440            )]
441            ForecastedReport = self.env['report.stock.report_product_product_replenishment']
442            forecast_lines = ForecastedReport.with_context(warehouse=warehouse.id)._get_report_lines(None, product_variant_ids, wh_location_ids)
443            for move in moves:
444                lines = [l for l in forecast_lines if l["move_out"] == move._origin and l["replenishment_filled"] is True]
445                if lines:
446                    move.forecast_availability = sum(m['quantity'] for m in lines)
447                    move_ins_lines = list(filter(lambda report_line: report_line['move_in'], lines))
448                    if move_ins_lines:
449                        expected_date = max(m['move_in'].date for m in move_ins_lines)
450                        move.forecast_expected_date = expected_date
451
452    def _set_date_deadline(self, new_deadline):
453        # Handle the propagation of `date_deadline` fields (up and down stream - only update by up/downstream documents)
454        already_propagate_ids = self.env.context.get('date_deadline_propagate_ids', set()) | set(self.ids)
455        self = self.with_context(date_deadline_propagate_ids=already_propagate_ids)
456        for move in self:
457            moves_to_update = (move.move_dest_ids | move.move_orig_ids)
458            if move.date_deadline:
459                delta = move.date_deadline - fields.Datetime.to_datetime(new_deadline)
460            else:
461                delta = 0
462            for move_update in moves_to_update:
463                if move_update.state in ('done', 'cancel'):
464                    continue
465                if move_update.id in already_propagate_ids:
466                    continue
467                if move_update.date_deadline and delta:
468                    move_update.date_deadline -= delta
469                else:
470                    move_update.date_deadline = new_deadline
471
472    @api.depends('move_line_ids', 'move_line_ids.lot_id', 'move_line_ids.qty_done')
473    def _compute_lot_ids(self):
474        domain_nosuggest = [('move_id', 'in', self.ids), ('lot_id', '!=', False), '|', ('qty_done', '!=', 0.0), ('product_qty', '=', 0.0)]
475        domain_suggest = [('move_id', 'in', self.ids), ('lot_id', '!=', False), ('qty_done', '!=', 0.0)]
476        lots_by_move_id_list = []
477        for domain in [domain_nosuggest, domain_suggest]:
478            lots_by_move_id = self.env['stock.move.line'].read_group(
479                domain,
480                ['move_id', 'lot_ids:array_agg(lot_id)'], ['move_id'],
481            )
482            lots_by_move_id_list.append({by_move['move_id'][0]: by_move['lot_ids'] for by_move in lots_by_move_id})
483        for move in self:
484            move.lot_ids = lots_by_move_id_list[0 if move.picking_type_id.show_reserved else 1].get(move._origin.id, [])
485
486    def _set_lot_ids(self):
487        for move in self:
488            move_lines_commands = []
489            if move.picking_type_id.show_reserved is False:
490                mls = move.move_line_nosuggest_ids
491            else:
492                mls = move.move_line_ids
493            mls = mls.filtered(lambda ml: ml.lot_id)
494            for ml in mls:
495                if ml.qty_done and ml.lot_id not in move.lot_ids:
496                    move_lines_commands.append((2, ml.id))
497            ls = move.move_line_ids.lot_id
498            for lot in move.lot_ids:
499                if lot not in ls:
500                    move_line_vals = self._prepare_move_line_vals(quantity=0)
501                    move_line_vals['lot_id'] = lot.id
502                    move_line_vals['lot_name'] = lot.name
503                    move_line_vals['product_uom_id'] = move.product_id.uom_id.id
504                    move_line_vals['qty_done'] = 1
505                    move_lines_commands.append((0, 0, move_line_vals))
506            move.write({'move_line_ids': move_lines_commands})
507
508    @api.constrains('product_uom')
509    def _check_uom(self):
510        moves_error = self.filtered(lambda move: move.product_id.uom_id.category_id != move.product_uom.category_id)
511        if moves_error:
512            user_warning = _('You cannot perform the move because the unit of measure has a different category as the product unit of measure.')
513            for move in moves_error:
514                user_warning += _('\n\n%s --> Product UoM is %s (%s) - Move UoM is %s (%s)') % (move.product_id.display_name, move.product_id.uom_id.name, move.product_id.uom_id.category_id.name, move.product_uom.name, move.product_uom.category_id.name)
515            user_warning += _('\n\nBlocking: %s') % ' ,'.join(moves_error.mapped('name'))
516            raise UserError(user_warning)
517
518    def init(self):
519        self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('stock_move_product_location_index',))
520        if not self._cr.fetchone():
521            self._cr.execute('CREATE INDEX stock_move_product_location_index ON stock_move (product_id, location_id, location_dest_id, company_id, state)')
522
523    @api.model
524    def default_get(self, fields_list):
525        # We override the default_get to make stock moves created after the picking was confirmed
526        # directly as available in immediate transfer mode. This allows to create extra move lines
527        # in the fp view. In planned transfer, the stock move are marked as `additional` and will be
528        # auto-confirmed.
529        defaults = super(StockMove, self).default_get(fields_list)
530        if self.env.context.get('default_picking_id'):
531            picking_id = self.env['stock.picking'].browse(self.env.context['default_picking_id'])
532            if picking_id.state == 'done':
533                defaults['state'] = 'done'
534                defaults['product_uom_qty'] = 0.0
535                defaults['additional'] = True
536            elif picking_id.state not in ['cancel', 'draft', 'done']:
537                if picking_id.immediate_transfer:
538                    defaults['state'] = 'assigned'
539                defaults['product_uom_qty'] = 0.0
540                defaults['additional'] = True  # to trigger `_autoconfirm_picking`
541        return defaults
542
543    def name_get(self):
544        res = []
545        for move in self:
546            res.append((move.id, '%s%s%s>%s' % (
547                move.picking_id.origin and '%s/' % move.picking_id.origin or '',
548                move.product_id.code and '%s: ' % move.product_id.code or '',
549                move.location_id.name, move.location_dest_id.name)))
550        return res
551
552    def write(self, vals):
553        # Handle the write on the initial demand by updating the reserved quantity and logging
554        # messages according to the state of the stock.move records.
555        receipt_moves_to_reassign = self.env['stock.move']
556        move_to_recompute_state = self.env['stock.move']
557        if 'product_uom_qty' in vals:
558            move_to_unreserve = self.env['stock.move']
559            for move in self.filtered(lambda m: m.state not in ('done', 'draft') and m.picking_id):
560                if float_compare(vals['product_uom_qty'], move.product_uom_qty, precision_rounding=move.product_uom.rounding):
561                    self.env['stock.move.line']._log_message(move.picking_id, move, 'stock.track_move_template', vals)
562            if self.env.context.get('do_not_unreserve') is None:
563                move_to_unreserve = self.filtered(
564                    lambda m: m.state not in ['draft', 'done', 'cancel'] and float_compare(m.reserved_availability, vals.get('product_uom_qty'), precision_rounding=m.product_uom.rounding) == 1
565                )
566                move_to_unreserve._do_unreserve()
567                (self - move_to_unreserve).filtered(lambda m: m.state == 'assigned').write({'state': 'partially_available'})
568                # When editing the initial demand, directly run again action assign on receipt moves.
569                receipt_moves_to_reassign |= move_to_unreserve.filtered(lambda m: m.location_id.usage == 'supplier')
570                receipt_moves_to_reassign |= (self - move_to_unreserve).filtered(lambda m: m.location_id.usage == 'supplier' and m.state in ('partially_available', 'assigned'))
571                move_to_recompute_state |= self - move_to_unreserve - receipt_moves_to_reassign
572        if 'date_deadline' in vals:
573            self._set_date_deadline(vals.get('date_deadline'))
574        res = super(StockMove, self).write(vals)
575        if move_to_recompute_state:
576            move_to_recompute_state._recompute_state()
577        if receipt_moves_to_reassign:
578            receipt_moves_to_reassign._action_assign()
579        return res
580
581    def _delay_alert_get_documents(self):
582        """Returns a list of recordset of the documents linked to the stock.move in `self` in order
583        to post the delay alert next activity. These documents are deduplicated. This method is meant
584        to be overridden by other modules, each of them adding an element by type of recordset on
585        this list.
586
587        :return: a list of recordset of the documents linked to `self`
588        :rtype: list
589        """
590        return list(self.mapped('picking_id'))
591
592    def _propagate_date_log_note(self, move_orig):
593        """Post a deadline change alert log note on the documents linked to `self`."""
594        # TODO : get the end document (PO/SO/MO)
595        doc_orig = move_orig._delay_alert_get_documents()
596        documents = self._delay_alert_get_documents()
597        if not documents or not doc_orig:
598            return
599
600        msg = _("The deadline has been automatically updated due to a delay on <a href='#' data-oe-model='%s' data-oe-id='%s'>%s</a>.") % (doc_orig[0]._name, doc_orig[0].id, doc_orig[0].name)
601        msg_subject = _("Deadline updated due to delay on %s", doc_orig[0].name)
602        # write the message on each document
603        for doc in documents:
604            last_message = doc.message_ids[:1]
605            # Avoids to write the exact same message multiple times.
606            if last_message and last_message.subject == msg_subject:
607                continue
608            odoobot_id = self.env['ir.model.data'].xmlid_to_res_id("base.partner_root")
609            doc.message_post(body=msg, author_id=odoobot_id, subject=msg_subject)
610
611    def action_show_details(self):
612        """ Returns an action that will open a form view (in a popup) allowing to work on all the
613        move lines of a particular move. This form view is used when "show operations" is not
614        checked on the picking type.
615        """
616        self.ensure_one()
617
618        picking_type_id = self.picking_type_id or self.picking_id.picking_type_id
619
620        # If "show suggestions" is not checked on the picking type, we have to filter out the
621        # reserved move lines. We do this by displaying `move_line_nosuggest_ids`. We use
622        # different views to display one field or another so that the webclient doesn't have to
623        # fetch both.
624        if picking_type_id.show_reserved:
625            view = self.env.ref('stock.view_stock_move_operations')
626        else:
627            view = self.env.ref('stock.view_stock_move_nosuggest_operations')
628
629        return {
630            'name': _('Detailed Operations'),
631            'type': 'ir.actions.act_window',
632            'view_mode': 'form',
633            'res_model': 'stock.move',
634            'views': [(view.id, 'form')],
635            'view_id': view.id,
636            'target': 'new',
637            'res_id': self.id,
638            'context': dict(
639                self.env.context,
640                show_owner=self.picking_type_id.code != 'incoming',
641                show_lots_m2o=self.has_tracking != 'none' and (picking_type_id.use_existing_lots or self.state == 'done' or self.origin_returned_move_id.id),  # able to create lots, whatever the value of ` use_create_lots`.
642                show_lots_text=self.has_tracking != 'none' and picking_type_id.use_create_lots and not picking_type_id.use_existing_lots and self.state != 'done' and not self.origin_returned_move_id.id,
643                show_source_location=self.picking_type_id.code != 'incoming',
644                show_destination_location=self.picking_type_id.code != 'outgoing',
645                show_package=not self.location_id.usage == 'supplier',
646                show_reserved_quantity=self.state != 'done' and not self.picking_id.immediate_transfer and self.picking_type_id.code != 'incoming'
647            ),
648        }
649
650    def action_assign_serial_show_details(self):
651        """ On `self.move_line_ids`, assign `lot_name` according to
652        `self.next_serial` before returning `self.action_show_details`.
653        """
654        self.ensure_one()
655        if not self.next_serial:
656            raise UserError(_("You need to set a Serial Number before generating more."))
657        self._generate_serial_numbers()
658        return self.action_show_details()
659
660    def action_clear_lines_show_details(self):
661        """ Unlink `self.move_line_ids` before returning `self.action_show_details`.
662        Useful for if a user creates too many SNs by accident via action_assign_serial_show_details
663        since there's no way to undo the action.
664        """
665        self.ensure_one()
666        if self.picking_type_id.show_reserved:
667            move_lines = self.move_line_ids
668        else:
669            move_lines = self.move_line_nosuggest_ids
670        move_lines.unlink()
671        return self.action_show_details()
672
673    def action_assign_serial(self):
674        """ Opens a wizard to assign SN's name on each move lines.
675        """
676        self.ensure_one()
677        action = self.env["ir.actions.actions"]._for_xml_id("stock.act_assign_serial_numbers")
678        action['context'] = {
679            'default_product_id': self.product_id.id,
680            'default_move_id': self.id,
681        }
682        return action
683
684    def action_product_forecast_report(self):
685        self.ensure_one()
686        action = self.product_id.action_product_forecast_report()
687        warehouse = self.location_id.get_warehouse()
688        action['context'] = {'warehouse': warehouse.id, } if warehouse else {}
689        return action
690
691    def _do_unreserve(self):
692        moves_to_unreserve = OrderedSet()
693        for move in self:
694            if move.state == 'cancel' or (move.state == 'done' and move.scrapped):
695                # We may have cancelled move in an open picking in a "propagate_cancel" scenario.
696                # We may have done move in an open picking in a scrap scenario.
697                continue
698            elif move.state == 'done':
699                raise UserError(_("You cannot unreserve a stock move that has been set to 'Done'."))
700            moves_to_unreserve.add(move.id)
701        moves_to_unreserve = self.env['stock.move'].browse(moves_to_unreserve)
702
703        ml_to_update, ml_to_unlink = OrderedSet(), OrderedSet()
704        moves_not_to_recompute = OrderedSet()
705        for ml in moves_to_unreserve.move_line_ids:
706            if ml.qty_done:
707                ml_to_update.add(ml.id)
708            else:
709                ml_to_unlink.add(ml.id)
710                moves_not_to_recompute.add(ml.move_id.id)
711        ml_to_update, ml_to_unlink = self.env['stock.move.line'].browse(ml_to_update), self.env['stock.move.line'].browse(ml_to_unlink)
712        moves_not_to_recompute = self.env['stock.move'].browse(moves_not_to_recompute)
713
714        ml_to_update.write({'product_uom_qty': 0})
715        ml_to_unlink.unlink()
716        # `write` on `stock.move.line` doesn't call `_recompute_state` (unlike to `unlink`),
717        # so it must be called for each move where no move line has been deleted.
718        (moves_to_unreserve - moves_not_to_recompute)._recompute_state()
719        return True
720
721    def _generate_serial_numbers(self, next_serial_count=False):
722        """ This method will generate `lot_name` from a string (field
723        `next_serial`) and create a move line for each generated `lot_name`.
724        """
725        self.ensure_one()
726
727        if not next_serial_count:
728            next_serial_count = self.next_serial_count
729        # We look if the serial number contains at least one digit.
730        caught_initial_number = regex_findall("\d+", self.next_serial)
731        if not caught_initial_number:
732            raise UserError(_('The serial number must contain at least one digit.'))
733        # We base the serie on the last number find in the base serial number.
734        initial_number = caught_initial_number[-1]
735        padding = len(initial_number)
736        # We split the serial number to get the prefix and suffix.
737        splitted = regex_split(initial_number, self.next_serial)
738        # initial_number could appear several times in the SN, e.g. BAV023B00001S00001
739        prefix = initial_number.join(splitted[:-1])
740        suffix = splitted[-1]
741        initial_number = int(initial_number)
742
743        lot_names = []
744        for i in range(0, next_serial_count):
745            lot_names.append('%s%s%s' % (
746                prefix,
747                str(initial_number + i).zfill(padding),
748                suffix
749            ))
750        move_lines_commands = self._generate_serial_move_line_commands(lot_names)
751        self.write({'move_line_ids': move_lines_commands})
752        return True
753
754    def _push_apply(self):
755        for move in self:
756            # if the move is already chained, there is no need to check push rules
757            if move.move_dest_ids:
758                continue
759            # if the move is a returned move, we don't want to check push rules, as returning a returned move is the only decent way
760            # to receive goods without triggering the push rules again (which would duplicate chained operations)
761            domain = [('location_src_id', '=', move.location_dest_id.id), ('action', 'in', ('push', 'pull_push'))]
762            # first priority goes to the preferred routes defined on the move itself (e.g. coming from a SO line)
763            warehouse_id = move.warehouse_id or move.picking_id.picking_type_id.warehouse_id
764            if move.location_dest_id.company_id == self.env.company:
765                rules = self.env['procurement.group']._search_rule(move.route_ids, move.product_id, warehouse_id, domain)
766            else:
767                rules = self.sudo().env['procurement.group']._search_rule(move.route_ids, move.product_id, warehouse_id, domain)
768            # Make sure it is not returning the return
769            if rules and (not move.origin_returned_move_id or move.origin_returned_move_id.location_dest_id.id != rules.location_id.id):
770                rules._run_push(move)
771
772    def _merge_moves_fields(self):
773        """ This method will return a dict of stock move’s values that represent the values of all moves in `self` merged. """
774        state = self._get_relevant_state_among_moves()
775        origin = '/'.join(set(self.filtered(lambda m: m.origin).mapped('origin')))
776        return {
777            'product_uom_qty': sum(self.mapped('product_uom_qty')),
778            'date': min(self.mapped('date')) if self.mapped('picking_id').move_type == 'direct' else max(self.mapped('date')),
779            'move_dest_ids': [(4, m.id) for m in self.mapped('move_dest_ids')],
780            'move_orig_ids': [(4, m.id) for m in self.mapped('move_orig_ids')],
781            'state': state,
782            'origin': origin,
783        }
784
785    @api.model
786    def _prepare_merge_moves_distinct_fields(self):
787        return [
788            'product_id', 'price_unit', 'procure_method', 'location_id', 'location_dest_id',
789            'product_uom', 'restrict_partner_id', 'scrapped', 'origin_returned_move_id',
790            'package_level_id', 'propagate_cancel', 'description_picking', 'date_deadline'
791        ]
792
793    @api.model
794    def _prepare_merge_move_sort_method(self, move):
795        move.ensure_one()
796
797        description_picking = move.description_picking or ""
798
799        return [
800            move.product_id.id, move.price_unit, move.procure_method, move.location_id, move.location_dest_id,
801            move.product_uom.id, move.restrict_partner_id.id, move.scrapped, move.origin_returned_move_id.id,
802            move.package_level_id.id, move.propagate_cancel, description_picking
803        ]
804
805    def _clean_merged(self):
806        """Cleanup hook used when merging moves"""
807        self.write({'propagate_cancel': False})
808
809    def _merge_moves(self, merge_into=False):
810        """ This method will, for each move in `self`, go up in their linked picking and try to
811        find in their existing moves a candidate into which we can merge the move.
812        :return: Recordset of moves passed to this method. If some of the passed moves were merged
813        into another existing one, return this one and not the (now unlinked) original.
814        """
815        distinct_fields = self._prepare_merge_moves_distinct_fields()
816
817        candidate_moves_list = []
818        if not merge_into:
819            for picking in self.mapped('picking_id'):
820                candidate_moves_list.append(picking.move_lines)
821        else:
822            candidate_moves_list.append(merge_into | self)
823
824        # Move removed after merge
825        moves_to_unlink = self.env['stock.move']
826        moves_to_merge = []
827        for candidate_moves in candidate_moves_list:
828            # First step find move to merge.
829            candidate_moves = candidate_moves.with_context(prefetch_fields=False)
830            for k, g in groupby(sorted(candidate_moves, key=self._prepare_merge_move_sort_method), key=itemgetter(*distinct_fields)):
831                moves = self.env['stock.move'].concat(*g).filtered(lambda m: m.state not in ('done', 'cancel', 'draft'))
832                # If we have multiple records we will merge then in a single one.
833                if len(moves) > 1:
834                    moves_to_merge.append(moves)
835
836        # second step merge its move lines, initial demand, ...
837        for moves in moves_to_merge:
838            # link all move lines to record 0 (the one we will keep).
839            moves.mapped('move_line_ids').write({'move_id': moves[0].id})
840            # merge move data
841            moves[0].write(moves._merge_moves_fields())
842            # update merged moves dicts
843            moves_to_unlink |= moves[1:]
844
845        if moves_to_unlink:
846            # We are using propagate to False in order to not cancel destination moves merged in moves[0]
847            moves_to_unlink._clean_merged()
848            moves_to_unlink._action_cancel()
849            moves_to_unlink.sudo().unlink()
850        return (self | self.env['stock.move'].concat(*moves_to_merge)) - moves_to_unlink
851
852    def _get_relevant_state_among_moves(self):
853        # We sort our moves by importance of state:
854        #     ------------- 0
855        #     | Assigned  |
856        #     -------------
857        #     |  Waiting  |
858        #     -------------
859        #     |  Partial  |
860        #     -------------
861        #     |  Confirm  |
862        #     ------------- len-1
863        sort_map = {
864            'assigned': 4,
865            'waiting': 3,
866            'partially_available': 2,
867            'confirmed': 1,
868        }
869        moves_todo = self\
870            .filtered(lambda move: move.state not in ['cancel', 'done'] and not (move.state == 'assigned' and not move.product_uom_qty))\
871            .sorted(key=lambda move: (sort_map.get(move.state, 0), move.product_uom_qty))
872        if not moves_todo:
873            return 'assigned'
874        # The picking should be the same for all moves.
875        if moves_todo[:1].picking_id and moves_todo[:1].picking_id.move_type == 'one':
876            most_important_move = moves_todo[0]
877            if most_important_move.state == 'confirmed':
878                return 'confirmed' if most_important_move.product_uom_qty else 'assigned'
879            elif most_important_move.state == 'partially_available':
880                return 'confirmed'
881            else:
882                return moves_todo[:1].state or 'draft'
883        elif moves_todo[:1].state != 'assigned' and any(move.state in ['assigned', 'partially_available'] for move in moves_todo):
884            return 'partially_available'
885        else:
886            least_important_move = moves_todo[-1:]
887            if least_important_move.state == 'confirmed' and least_important_move.product_uom_qty == 0:
888                return 'assigned'
889            else:
890                return moves_todo[-1:].state or 'draft'
891
892    @api.onchange('product_id')
893    def onchange_product_id(self):
894        product = self.product_id.with_context(lang=self._get_lang())
895        self.name = product.partner_ref
896        self.product_uom = product.uom_id.id
897
898    @api.onchange('lot_ids')
899    def _onchange_lot_ids(self):
900        quantity_done = sum(ml.product_uom_id._compute_quantity(ml.qty_done, self.product_uom) for ml in self.move_line_ids.filtered(lambda ml: not ml.lot_id and ml.lot_name))
901        quantity_done += self.product_id.uom_id._compute_quantity(len(self.lot_ids), self.product_uom)
902        self.update({'quantity_done': quantity_done})
903        used_lots = self.env['stock.move.line'].search([
904            ('company_id', '=', self.company_id.id),
905            ('product_id', '=', self.product_id.id),
906            ('lot_id', 'in', self.lot_ids.ids),
907            ('move_id', '!=', self._origin.id),
908            ('state', '!=', 'cancel')
909        ])
910        if used_lots:
911            return {
912                'warning': {'title': _('Warning'), 'message': _('Existing Serial numbers (%s). Please correct the serial numbers encoded.') % ','.join(used_lots.lot_id.mapped('display_name'))}
913            }
914
915    @api.onchange('move_line_ids', 'move_line_nosuggest_ids')
916    def onchange_move_line_ids(self):
917        if not self.picking_type_id.use_create_lots:
918            # This onchange manages the creation of multiple lot name. We don't
919            # need that if the picking type disallows the creation of new lots.
920            return
921
922        breaking_char = '\n'
923        if self.picking_type_id.show_reserved:
924            move_lines = self.move_line_ids
925        else:
926            move_lines = self.move_line_nosuggest_ids
927
928        for move_line in move_lines:
929            # Look if the `lot_name` contains multiple values.
930            if breaking_char in (move_line.lot_name or ''):
931                split_lines = move_line.lot_name.split(breaking_char)
932                split_lines = list(filter(None, split_lines))
933                move_line.lot_name = split_lines[0]
934                move_lines_commands = self._generate_serial_move_line_commands(
935                    split_lines[1:],
936                    origin_move_line=move_line,
937                )
938                if self.picking_type_id.show_reserved:
939                    self.update({'move_line_ids': move_lines_commands})
940                else:
941                    self.update({'move_line_nosuggest_ids': move_lines_commands})
942                existing_lots = self.env['stock.production.lot'].search([
943                    ('company_id', '=', self.company_id.id),
944                    ('product_id', '=', self.product_id.id),
945                    ('name', 'in', split_lines),
946                ])
947                if existing_lots:
948                    return {
949                        'warning': {'title': _('Warning'), 'message': _('Existing Serial Numbers (%s). Please correct the serial numbers encoded.') % ','.join(existing_lots.mapped('display_name'))}
950                    }
951                break
952
953    @api.onchange('product_uom')
954    def onchange_product_uom(self):
955        if self.product_uom.factor > self.product_id.uom_id.factor:
956            return {
957                'warning': {
958                    'title': "Unsafe unit of measure",
959                    'message': _("You are using a unit of measure smaller than the one you are using in "
960                                 "order to stock your product. This can lead to rounding problem on reserved quantity. "
961                                 "You should use the smaller unit of measure possible in order to valuate your stock or "
962                                 "change its rounding precision to a smaller value (example: 0.00001)."),
963                }
964            }
965
966    def _key_assign_picking(self):
967        self.ensure_one()
968        return self.group_id, self.location_id, self.location_dest_id, self.picking_type_id
969
970    def _search_picking_for_assignation(self):
971        self.ensure_one()
972        picking = self.env['stock.picking'].search([
973                ('group_id', '=', self.group_id.id),
974                ('location_id', '=', self.location_id.id),
975                ('location_dest_id', '=', self.location_dest_id.id),
976                ('picking_type_id', '=', self.picking_type_id.id),
977                ('printed', '=', False),
978                ('immediate_transfer', '=', False),
979                ('state', 'in', ['draft', 'confirmed', 'waiting', 'partially_available', 'assigned'])], limit=1)
980        return picking
981
982    def _assign_picking(self):
983        """ Try to assign the moves to an existing picking that has not been
984        reserved yet and has the same procurement group, locations and picking
985        type (moves should already have them identical). Otherwise, create a new
986        picking to assign them to. """
987        Picking = self.env['stock.picking']
988        grouped_moves = groupby(sorted(self, key=lambda m: [f.id for f in m._key_assign_picking()]), key=lambda m: [m._key_assign_picking()])
989        for group, moves in grouped_moves:
990            moves = self.env['stock.move'].concat(*list(moves))
991            new_picking = False
992            # Could pass the arguments contained in group but they are the same
993            # for each move that why moves[0] is acceptable
994            picking = moves[0]._search_picking_for_assignation()
995            if picking:
996                if any(picking.partner_id.id != m.partner_id.id or
997                        picking.origin != m.origin for m in moves):
998                    # If a picking is found, we'll append `move` to its move list and thus its
999                    # `partner_id` and `ref` field will refer to multiple records. In this
1000                    # case, we chose to  wipe them.
1001                    picking.write({
1002                        'partner_id': False,
1003                        'origin': False,
1004                    })
1005            else:
1006                new_picking = True
1007                picking = Picking.create(moves._get_new_picking_values())
1008
1009            moves.write({'picking_id': picking.id})
1010            moves._assign_picking_post_process(new=new_picking)
1011        return True
1012
1013    def _assign_picking_post_process(self, new=False):
1014        pass
1015
1016    def _generate_serial_move_line_commands(self, lot_names, origin_move_line=None):
1017        """Return a list of commands to update the move lines (write on
1018        existing ones or create new ones).
1019        Called when user want to create and assign multiple serial numbers in
1020        one time (using the button/wizard or copy-paste a list in the field).
1021
1022        :param lot_names: A list containing all serial number to assign.
1023        :type lot_names: list
1024        :param origin_move_line: A move line to duplicate the value from, default to None
1025        :type origin_move_line: record of :class:`stock.move.line`
1026        :return: A list of commands to create/update :class:`stock.move.line`
1027        :rtype: list
1028        """
1029        self.ensure_one()
1030
1031        # Select the right move lines depending of the picking type configuration.
1032        move_lines = self.env['stock.move.line']
1033        if self.picking_type_id.show_reserved:
1034            move_lines = self.move_line_ids.filtered(lambda ml: not ml.lot_id and not ml.lot_name)
1035        else:
1036            move_lines = self.move_line_nosuggest_ids.filtered(lambda ml: not ml.lot_id and not ml.lot_name)
1037
1038        if origin_move_line:
1039            location_dest = origin_move_line.location_dest_id
1040        else:
1041            location_dest = self.location_dest_id._get_putaway_strategy(self.product_id)
1042        move_line_vals = {
1043            'picking_id': self.picking_id.id,
1044            'location_dest_id': location_dest.id or self.location_dest_id.id,
1045            'location_id': self.location_id.id,
1046            'product_id': self.product_id.id,
1047            'product_uom_id': self.product_id.uom_id.id,
1048            'qty_done': 1,
1049        }
1050        if origin_move_line:
1051            # `owner_id` and `package_id` are taken only in the case we create
1052            # new move lines from an existing move line. Also, updates the
1053            # `qty_done` because it could be usefull for products tracked by lot.
1054            move_line_vals.update({
1055                'owner_id': origin_move_line.owner_id.id,
1056                'package_id': origin_move_line.package_id.id,
1057                'qty_done': origin_move_line.qty_done or 1,
1058            })
1059
1060        move_lines_commands = []
1061        for lot_name in lot_names:
1062            # We write the lot name on an existing move line (if we have still one)...
1063            if move_lines:
1064                move_lines_commands.append((1, move_lines[0].id, {
1065                    'lot_name': lot_name,
1066                    'qty_done': 1,
1067                }))
1068                move_lines = move_lines[1:]
1069            # ... or create a new move line with the serial name.
1070            else:
1071                move_line_cmd = dict(move_line_vals, lot_name=lot_name)
1072                move_lines_commands.append((0, 0, move_line_cmd))
1073        return move_lines_commands
1074
1075    def _get_new_picking_values(self):
1076        """ return create values for new picking that will be linked with group
1077        of moves in self.
1078        """
1079        origins = self.filtered(lambda m: m.origin).mapped('origin')
1080        origins = list(dict.fromkeys(origins)) # create a list of unique items
1081        # Will display source document if any, when multiple different origins
1082        # are found display a maximum of 5
1083        if len(origins) == 0:
1084            origin = False
1085        else:
1086            origin = ','.join(origins[:5])
1087            if len(origins) > 5:
1088                origin += "..."
1089        partners = self.mapped('partner_id')
1090        partner = len(partners) == 1 and partners.id or False
1091        return {
1092            'origin': origin,
1093            'company_id': self.mapped('company_id').id,
1094            'user_id': False,
1095            'move_type': self.mapped('group_id').move_type or 'direct',
1096            'partner_id': partner,
1097            'picking_type_id': self.mapped('picking_type_id').id,
1098            'location_id': self.mapped('location_id').id,
1099            'location_dest_id': self.mapped('location_dest_id').id,
1100        }
1101
1102    def _should_be_assigned(self):
1103        self.ensure_one()
1104        return bool(not self.picking_id and self.picking_type_id)
1105
1106    def _action_confirm(self, merge=True, merge_into=False):
1107        """ Confirms stock move or put it in waiting if it's linked to another move.
1108        :param: merge: According to this boolean, a newly confirmed move will be merged
1109        in another move of the same picking sharing its characteristics.
1110        """
1111        move_create_proc = self.env['stock.move']
1112        move_to_confirm = self.env['stock.move']
1113        move_waiting = self.env['stock.move']
1114
1115        to_assign = {}
1116        for move in self:
1117            if move.state != 'draft':
1118                continue
1119            # if the move is preceeded, then it's waiting (if preceeding move is done, then action_assign has been called already and its state is already available)
1120            if move.move_orig_ids:
1121                move_waiting |= move
1122            else:
1123                if move.procure_method == 'make_to_order':
1124                    move_create_proc |= move
1125                else:
1126                    move_to_confirm |= move
1127            if move._should_be_assigned():
1128                key = (move.group_id.id, move.location_id.id, move.location_dest_id.id)
1129                if key not in to_assign:
1130                    to_assign[key] = self.env['stock.move']
1131                to_assign[key] |= move
1132
1133        # create procurements for make to order moves
1134        procurement_requests = []
1135        for move in move_create_proc:
1136            values = move._prepare_procurement_values()
1137            origin = (move.group_id and move.group_id.name or (move.origin or move.picking_id.name or "/"))
1138            procurement_requests.append(self.env['procurement.group'].Procurement(
1139                move.product_id, move.product_uom_qty, move.product_uom,
1140                move.location_id, move.rule_id and move.rule_id.name or "/",
1141                origin, move.company_id, values))
1142        self.env['procurement.group'].run(procurement_requests, raise_user_error=not self.env.context.get('from_orderpoint'))
1143
1144        move_to_confirm.write({'state': 'confirmed'})
1145        (move_waiting | move_create_proc).write({'state': 'waiting'})
1146
1147        # assign picking in batch for all confirmed move that share the same details
1148        for moves in to_assign.values():
1149            moves._assign_picking()
1150        self._push_apply()
1151        self._check_company()
1152        moves = self
1153        if merge:
1154            moves = self._merge_moves(merge_into=merge_into)
1155        # call `_action_assign` on every confirmed move which location_id bypasses the reservation
1156        moves.filtered(lambda move: not move.picking_id.immediate_transfer and move._should_bypass_reservation() and move.state == 'confirmed')._action_assign()
1157        return moves
1158
1159    def _prepare_procurement_values(self):
1160        """ Prepare specific key for moves or other componenets that will be created from a stock rule
1161        comming from a stock move. This method could be override in order to add other custom key that could
1162        be used in move/po creation.
1163        """
1164        self.ensure_one()
1165        group_id = self.group_id or False
1166        if self.rule_id:
1167            if self.rule_id.group_propagation_option == 'fixed' and self.rule_id.group_id:
1168                group_id = self.rule_id.group_id
1169            elif self.rule_id.group_propagation_option == 'none':
1170                group_id = False
1171        product_id = self.product_id.with_context(lang=self._get_lang())
1172        return {
1173            'product_description_variants': self.description_picking and self.description_picking.replace(product_id._get_description(self.picking_type_id), ''),
1174            'date_planned': self.date,
1175            'date_deadline': self.date_deadline,
1176            'move_dest_ids': self,
1177            'group_id': group_id,
1178            'route_ids': self.route_ids,
1179            'warehouse_id': self.warehouse_id or self.picking_id.picking_type_id.warehouse_id or self.picking_type_id.warehouse_id,
1180            'priority': self.priority,
1181            'orderpoint_id': self.orderpoint_id,
1182        }
1183
1184    def _prepare_move_line_vals(self, quantity=None, reserved_quant=None):
1185        self.ensure_one()
1186        # apply putaway
1187        location_dest_id = self.location_dest_id._get_putaway_strategy(self.product_id).id or self.location_dest_id.id
1188        vals = {
1189            'move_id': self.id,
1190            'product_id': self.product_id.id,
1191            'product_uom_id': self.product_uom.id,
1192            'location_id': self.location_id.id,
1193            'location_dest_id': location_dest_id,
1194            'picking_id': self.picking_id.id,
1195            'company_id': self.company_id.id,
1196        }
1197        if quantity:
1198            rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure')
1199            uom_quantity = self.product_id.uom_id._compute_quantity(quantity, self.product_uom, rounding_method='HALF-UP')
1200            uom_quantity = float_round(uom_quantity, precision_digits=rounding)
1201            uom_quantity_back_to_product_uom = self.product_uom._compute_quantity(uom_quantity, self.product_id.uom_id, rounding_method='HALF-UP')
1202            if float_compare(quantity, uom_quantity_back_to_product_uom, precision_digits=rounding) == 0:
1203                vals = dict(vals, product_uom_qty=uom_quantity)
1204            else:
1205                vals = dict(vals, product_uom_qty=quantity, product_uom_id=self.product_id.uom_id.id)
1206        if reserved_quant:
1207            vals = dict(
1208                vals,
1209                location_id=reserved_quant.location_id.id,
1210                lot_id=reserved_quant.lot_id.id or False,
1211                package_id=reserved_quant.package_id.id or False,
1212                owner_id =reserved_quant.owner_id.id or False,
1213            )
1214        return vals
1215
1216    def _update_reserved_quantity(self, need, available_quantity, location_id, lot_id=None, package_id=None, owner_id=None, strict=True):
1217        """ Create or update move lines.
1218        """
1219        self.ensure_one()
1220
1221        if not lot_id:
1222            lot_id = self.env['stock.production.lot']
1223        if not package_id:
1224            package_id = self.env['stock.quant.package']
1225        if not owner_id:
1226            owner_id = self.env['res.partner']
1227
1228        taken_quantity = min(available_quantity, need)
1229
1230        # `taken_quantity` is in the quants unit of measure. There's a possibility that the move's
1231        # unit of measure won't be respected if we blindly reserve this quantity, a common usecase
1232        # is if the move's unit of measure's rounding does not allow fractional reservation. We chose
1233        # to convert `taken_quantity` to the move's unit of measure with a down rounding method and
1234        # then get it back in the quants unit of measure with an half-up rounding_method. This
1235        # way, we'll never reserve more than allowed. We do not apply this logic if
1236        # `available_quantity` is brought by a chained move line. In this case, `_prepare_move_line_vals`
1237        # will take care of changing the UOM to the UOM of the product.
1238        if not strict and self.product_id.uom_id != self.product_uom:
1239            taken_quantity_move_uom = self.product_id.uom_id._compute_quantity(taken_quantity, self.product_uom, rounding_method='DOWN')
1240            taken_quantity = self.product_uom._compute_quantity(taken_quantity_move_uom, self.product_id.uom_id, rounding_method='HALF-UP')
1241
1242        quants = []
1243        rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure')
1244
1245        if self.product_id.tracking == 'serial':
1246            if float_compare(taken_quantity, int(taken_quantity), precision_digits=rounding) != 0:
1247                taken_quantity = 0
1248
1249        try:
1250            with self.env.cr.savepoint():
1251                if not float_is_zero(taken_quantity, precision_rounding=self.product_id.uom_id.rounding):
1252                    quants = self.env['stock.quant']._update_reserved_quantity(
1253                        self.product_id, location_id, taken_quantity, lot_id=lot_id,
1254                        package_id=package_id, owner_id=owner_id, strict=strict
1255                    )
1256        except UserError:
1257            taken_quantity = 0
1258
1259        # Find a candidate move line to update or create a new one.
1260        for reserved_quant, quantity in quants:
1261            to_update = self.move_line_ids.filtered(lambda ml: ml._reservation_is_updatable(quantity, reserved_quant))
1262            if to_update:
1263                uom_quantity = self.product_id.uom_id._compute_quantity(quantity, to_update[0].product_uom_id, rounding_method='HALF-UP')
1264                uom_quantity = float_round(uom_quantity, precision_digits=rounding)
1265                uom_quantity_back_to_product_uom = to_update[0].product_uom_id._compute_quantity(uom_quantity, self.product_id.uom_id, rounding_method='HALF-UP')
1266            if to_update and float_compare(quantity, uom_quantity_back_to_product_uom, precision_digits=rounding) == 0:
1267                to_update[0].with_context(bypass_reservation_update=True).product_uom_qty += uom_quantity
1268            else:
1269                if self.product_id.tracking == 'serial':
1270                    for i in range(0, int(quantity)):
1271                        self.env['stock.move.line'].create(self._prepare_move_line_vals(quantity=1, reserved_quant=reserved_quant))
1272                else:
1273                    self.env['stock.move.line'].create(self._prepare_move_line_vals(quantity=quantity, reserved_quant=reserved_quant))
1274        return taken_quantity
1275
1276    def _should_bypass_reservation(self):
1277        self.ensure_one()
1278        return self.location_id.should_bypass_reservation() or self.product_id.type != 'product'
1279
1280    # necessary hook to be able to override move reservation to a restrict lot, owner, pack, location...
1281    def _get_available_quantity(self, location_id, lot_id=None, package_id=None, owner_id=None, strict=False, allow_negative=False):
1282        self.ensure_one()
1283        return self.env['stock.quant']._get_available_quantity(self.product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict, allow_negative=allow_negative)
1284
1285    def _action_assign(self):
1286        """ Reserve stock moves by creating their stock move lines. A stock move is
1287        considered reserved once the sum of `product_qty` for all its move lines is
1288        equal to its `product_qty`. If it is less, the stock move is considered
1289        partially available.
1290        """
1291        StockMove = self.env['stock.move']
1292        assigned_moves_ids = OrderedSet()
1293        partially_available_moves_ids = OrderedSet()
1294        # Read the `reserved_availability` field of the moves out of the loop to prevent unwanted
1295        # cache invalidation when actually reserving the move.
1296        reserved_availability = {move: move.reserved_availability for move in self}
1297        roundings = {move: move.product_id.uom_id.rounding for move in self}
1298        move_line_vals_list = []
1299        for move in self.filtered(lambda m: m.state in ['confirmed', 'waiting', 'partially_available']):
1300            rounding = roundings[move]
1301            missing_reserved_uom_quantity = move.product_uom_qty - reserved_availability[move]
1302            missing_reserved_quantity = move.product_uom._compute_quantity(missing_reserved_uom_quantity, move.product_id.uom_id, rounding_method='HALF-UP')
1303            if move._should_bypass_reservation():
1304                # create the move line(s) but do not impact quants
1305                if move.product_id.tracking == 'serial' and (move.picking_type_id.use_create_lots or move.picking_type_id.use_existing_lots):
1306                    for i in range(0, int(missing_reserved_quantity)):
1307                        move_line_vals_list.append(move._prepare_move_line_vals(quantity=1))
1308                else:
1309                    to_update = move.move_line_ids.filtered(lambda ml: ml.product_uom_id == move.product_uom and
1310                                                            ml.location_id == move.location_id and
1311                                                            ml.location_dest_id == move.location_dest_id and
1312                                                            ml.picking_id == move.picking_id and
1313                                                            not ml.lot_id and
1314                                                            not ml.package_id and
1315                                                            not ml.owner_id)
1316                    if to_update:
1317                        to_update[0].product_uom_qty += missing_reserved_uom_quantity
1318                    else:
1319                        move_line_vals_list.append(move._prepare_move_line_vals(quantity=missing_reserved_quantity))
1320                assigned_moves_ids.add(move.id)
1321            else:
1322                if float_is_zero(move.product_uom_qty, precision_rounding=move.product_uom.rounding):
1323                    assigned_moves_ids.add(move.id)
1324                elif not move.move_orig_ids:
1325                    if move.procure_method == 'make_to_order':
1326                        continue
1327                    # If we don't need any quantity, consider the move assigned.
1328                    need = missing_reserved_quantity
1329                    if float_is_zero(need, precision_rounding=rounding):
1330                        assigned_moves_ids.add(move.id)
1331                        continue
1332                    # Reserve new quants and create move lines accordingly.
1333                    forced_package_id = move.package_level_id.package_id or None
1334                    available_quantity = move._get_available_quantity(move.location_id, package_id=forced_package_id)
1335                    if available_quantity <= 0:
1336                        continue
1337                    taken_quantity = move._update_reserved_quantity(need, available_quantity, move.location_id, package_id=forced_package_id, strict=False)
1338                    if float_is_zero(taken_quantity, precision_rounding=rounding):
1339                        continue
1340                    if float_compare(need, taken_quantity, precision_rounding=rounding) == 0:
1341                        assigned_moves_ids.add(move.id)
1342                    else:
1343                        partially_available_moves_ids.add(move.id)
1344                else:
1345                    # Check what our parents brought and what our siblings took in order to
1346                    # determine what we can distribute.
1347                    # `qty_done` is in `ml.product_uom_id` and, as we will later increase
1348                    # the reserved quantity on the quants, convert it here in
1349                    # `product_id.uom_id` (the UOM of the quants is the UOM of the product).
1350                    move_lines_in = move.move_orig_ids.filtered(lambda m: m.state == 'done').mapped('move_line_ids')
1351                    keys_in_groupby = ['location_dest_id', 'lot_id', 'result_package_id', 'owner_id']
1352
1353                    def _keys_in_sorted(ml):
1354                        return (ml.location_dest_id.id, ml.lot_id.id, ml.result_package_id.id, ml.owner_id.id)
1355
1356                    grouped_move_lines_in = {}
1357                    for k, g in groupby(sorted(move_lines_in, key=_keys_in_sorted), key=itemgetter(*keys_in_groupby)):
1358                        qty_done = 0
1359                        for ml in g:
1360                            qty_done += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id)
1361                        grouped_move_lines_in[k] = qty_done
1362                    move_lines_out_done = (move.move_orig_ids.mapped('move_dest_ids') - move)\
1363                        .filtered(lambda m: m.state in ['done'])\
1364                        .mapped('move_line_ids')
1365                    # As we defer the write on the stock.move's state at the end of the loop, there
1366                    # could be moves to consider in what our siblings already took.
1367                    moves_out_siblings = move.move_orig_ids.mapped('move_dest_ids') - move
1368                    moves_out_siblings_to_consider = moves_out_siblings & (StockMove.browse(assigned_moves_ids) + StockMove.browse(partially_available_moves_ids))
1369                    reserved_moves_out_siblings = moves_out_siblings.filtered(lambda m: m.state in ['partially_available', 'assigned'])
1370                    move_lines_out_reserved = (reserved_moves_out_siblings | moves_out_siblings_to_consider).mapped('move_line_ids')
1371                    keys_out_groupby = ['location_id', 'lot_id', 'package_id', 'owner_id']
1372
1373                    def _keys_out_sorted(ml):
1374                        return (ml.location_id.id, ml.lot_id.id, ml.package_id.id, ml.owner_id.id)
1375
1376                    grouped_move_lines_out = {}
1377                    for k, g in groupby(sorted(move_lines_out_done, key=_keys_out_sorted), key=itemgetter(*keys_out_groupby)):
1378                        qty_done = 0
1379                        for ml in g:
1380                            qty_done += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id)
1381                        grouped_move_lines_out[k] = qty_done
1382                    for k, g in groupby(sorted(move_lines_out_reserved, key=_keys_out_sorted), key=itemgetter(*keys_out_groupby)):
1383                        grouped_move_lines_out[k] = sum(self.env['stock.move.line'].concat(*list(g)).mapped('product_qty'))
1384                    available_move_lines = {key: grouped_move_lines_in[key] - grouped_move_lines_out.get(key, 0) for key in grouped_move_lines_in.keys()}
1385                    # pop key if the quantity available amount to 0
1386                    available_move_lines = dict((k, v) for k, v in available_move_lines.items() if v)
1387
1388                    if not available_move_lines:
1389                        continue
1390                    for move_line in move.move_line_ids.filtered(lambda m: m.product_qty):
1391                        if available_move_lines.get((move_line.location_id, move_line.lot_id, move_line.result_package_id, move_line.owner_id)):
1392                            available_move_lines[(move_line.location_id, move_line.lot_id, move_line.result_package_id, move_line.owner_id)] -= move_line.product_qty
1393                    for (location_id, lot_id, package_id, owner_id), quantity in available_move_lines.items():
1394                        need = move.product_qty - sum(move.move_line_ids.mapped('product_qty'))
1395                        # `quantity` is what is brought by chained done move lines. We double check
1396                        # here this quantity is available on the quants themselves. If not, this
1397                        # could be the result of an inventory adjustment that removed totally of
1398                        # partially `quantity`. When this happens, we chose to reserve the maximum
1399                        # still available. This situation could not happen on MTS move, because in
1400                        # this case `quantity` is directly the quantity on the quants themselves.
1401                        available_quantity = move._get_available_quantity(location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=True)
1402                        if float_is_zero(available_quantity, precision_rounding=rounding):
1403                            continue
1404                        taken_quantity = move._update_reserved_quantity(need, min(quantity, available_quantity), location_id, lot_id, package_id, owner_id)
1405                        if float_is_zero(taken_quantity, precision_rounding=rounding):
1406                            continue
1407                        if float_is_zero(need - taken_quantity, precision_rounding=rounding):
1408                            assigned_moves_ids.add(move.id)
1409                            break
1410                        partially_available_moves_ids.add(move.id)
1411            if move.product_id.tracking == 'serial':
1412                move.next_serial_count = move.product_uom_qty
1413
1414        self.env['stock.move.line'].create(move_line_vals_list)
1415        StockMove.browse(partially_available_moves_ids).write({'state': 'partially_available'})
1416        StockMove.browse(assigned_moves_ids).write({'state': 'assigned'})
1417        self.mapped('picking_id')._check_entire_pack()
1418
1419    def _action_cancel(self):
1420        if any(move.state == 'done' and not move.scrapped for move in self):
1421            raise UserError(_('You cannot cancel a stock move that has been set to \'Done\'. Create a return in order to reverse the moves which took place.'))
1422        moves_to_cancel = self.filtered(lambda m: m.state != 'cancel')
1423        # self cannot contain moves that are either cancelled or done, therefore we can safely
1424        # unlink all associated move_line_ids
1425        moves_to_cancel._do_unreserve()
1426
1427        for move in moves_to_cancel:
1428            siblings_states = (move.move_dest_ids.mapped('move_orig_ids') - move).mapped('state')
1429            if move.propagate_cancel:
1430                # only cancel the next move if all my siblings are also cancelled
1431                if all(state == 'cancel' for state in siblings_states):
1432                    move.move_dest_ids.filtered(lambda m: m.state != 'done')._action_cancel()
1433            else:
1434                if all(state in ('done', 'cancel') for state in siblings_states):
1435                    move.move_dest_ids.write({'procure_method': 'make_to_stock'})
1436                    move.move_dest_ids.write({'move_orig_ids': [(3, move.id, 0)]})
1437        self.write({
1438            'state': 'cancel',
1439            'move_orig_ids': [(5, 0, 0)],
1440            'procure_method': 'make_to_stock',
1441        })
1442        return True
1443
1444    def _prepare_extra_move_vals(self, qty):
1445        vals = {
1446            'procure_method': 'make_to_stock',
1447            'origin_returned_move_id': self.origin_returned_move_id.id,
1448            'product_uom_qty': qty,
1449            'picking_id': self.picking_id.id,
1450            'price_unit': self.price_unit,
1451        }
1452        return vals
1453
1454    def _create_extra_move(self):
1455        """ If the quantity done on a move exceeds its quantity todo, this method will create an
1456        extra move attached to a (potentially split) move line. If the previous condition is not
1457        met, it'll return an empty recordset.
1458
1459        The rationale for the creation of an extra move is the application of a potential push
1460        rule that will handle the extra quantities.
1461        """
1462        extra_move = self
1463        rounding = self.product_uom.rounding
1464        # moves created after the picking is assigned do not have `product_uom_qty`, but we shouldn't create extra moves for them
1465        if float_compare(self.quantity_done, self.product_uom_qty, precision_rounding=rounding) > 0:
1466            # create the extra moves
1467            extra_move_quantity = float_round(
1468                self.quantity_done - self.product_uom_qty,
1469                precision_rounding=rounding,
1470                rounding_method='HALF-UP')
1471            extra_move_vals = self._prepare_extra_move_vals(extra_move_quantity)
1472            extra_move = self.copy(default=extra_move_vals)
1473
1474            merge_into_self = all(self[field] == extra_move[field] for field in self._prepare_merge_moves_distinct_fields())
1475
1476            if merge_into_self and extra_move.picking_id:
1477                extra_move = extra_move._action_confirm(merge_into=self)
1478                return extra_move
1479            else:
1480                extra_move = extra_move._action_confirm()
1481
1482            # link it to some move lines. We don't need to do it for move since they should be merged.
1483            if not merge_into_self or not extra_move.picking_id:
1484                for move_line in self.move_line_ids.filtered(lambda ml: ml.qty_done):
1485                    if float_compare(move_line.qty_done, extra_move_quantity, precision_rounding=rounding) <= 0:
1486                        # move this move line to our extra move
1487                        move_line.move_id = extra_move.id
1488                        extra_move_quantity -= move_line.qty_done
1489                    else:
1490                        # split this move line and assign the new part to our extra move
1491                        quantity_split = float_round(
1492                            move_line.qty_done - extra_move_quantity,
1493                            precision_rounding=self.product_uom.rounding,
1494                            rounding_method='UP')
1495                        move_line.qty_done = quantity_split
1496                        move_line.copy(default={'move_id': extra_move.id, 'qty_done': extra_move_quantity, 'product_uom_qty': 0})
1497                        extra_move_quantity -= extra_move_quantity
1498                    if extra_move_quantity == 0.0:
1499                        break
1500        return extra_move | self
1501
1502    def _action_done(self, cancel_backorder=False):
1503        self.filtered(lambda move: move.state == 'draft')._action_confirm()  # MRP allows scrapping draft moves
1504        moves = self.exists().filtered(lambda x: x.state not in ('done', 'cancel'))
1505        moves_todo = self.env['stock.move']
1506
1507        # Cancel moves where necessary ; we should do it before creating the extra moves because
1508        # this operation could trigger a merge of moves.
1509        for move in moves:
1510            if move.quantity_done <= 0:
1511                if float_compare(move.product_uom_qty, 0.0, precision_rounding=move.product_uom.rounding) == 0 or cancel_backorder:
1512                    move._action_cancel()
1513
1514        # Create extra moves where necessary
1515        for move in moves:
1516            if move.state == 'cancel' or move.quantity_done <= 0:
1517                continue
1518
1519            moves_todo |= move._create_extra_move()
1520
1521        moves_todo._check_company()
1522        # Split moves where necessary and move quants
1523        backorder_moves_vals = []
1524        for move in moves_todo:
1525            # To know whether we need to create a backorder or not, round to the general product's
1526            # decimal precision and not the product's UOM.
1527            rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure')
1528            if float_compare(move.quantity_done, move.product_uom_qty, precision_digits=rounding) < 0:
1529                # Need to do some kind of conversion here
1530                qty_split = move.product_uom._compute_quantity(move.product_uom_qty - move.quantity_done, move.product_id.uom_id, rounding_method='HALF-UP')
1531                new_move_vals = move._split(qty_split)
1532                backorder_moves_vals += new_move_vals
1533        backorder_moves = self.env['stock.move'].create(backorder_moves_vals)
1534        backorder_moves._action_confirm(merge=False)
1535        if cancel_backorder:
1536            backorder_moves.with_context(moves_todo=moves_todo)._action_cancel()
1537        moves_todo.mapped('move_line_ids').sorted()._action_done()
1538        # Check the consistency of the result packages; there should be an unique location across
1539        # the contained quants.
1540        for result_package in moves_todo\
1541                .mapped('move_line_ids.result_package_id')\
1542                .filtered(lambda p: p.quant_ids and len(p.quant_ids) > 1):
1543            if len(result_package.quant_ids.filtered(lambda q: not float_is_zero(abs(q.quantity) + abs(q.reserved_quantity), precision_rounding=q.product_uom_id.rounding)).mapped('location_id')) > 1:
1544                raise UserError(_('You cannot move the same package content more than once in the same transfer or split the same package into two location.'))
1545        picking = moves_todo.mapped('picking_id')
1546        moves_todo.write({'state': 'done', 'date': fields.Datetime.now()})
1547
1548        move_dests_per_company = defaultdict(lambda: self.env['stock.move'])
1549        for move_dest in moves_todo.move_dest_ids:
1550            move_dests_per_company[move_dest.company_id.id] |= move_dest
1551        for company_id, move_dests in move_dests_per_company.items():
1552            move_dests.sudo().with_company(company_id)._action_assign()
1553
1554        # We don't want to create back order for scrap moves
1555        # Replace by a kwarg in master
1556        if self.env.context.get('is_scrap'):
1557            return moves_todo
1558
1559        if picking and not cancel_backorder:
1560            picking._create_backorder()
1561        return moves_todo
1562
1563    def unlink(self):
1564        if any(move.state not in ('draft', 'cancel') for move in self):
1565            raise UserError(_('You can only delete draft moves.'))
1566        # With the non plannified picking, draft moves could have some move lines.
1567        self.with_context(prefetch_fields=False).mapped('move_line_ids').unlink()
1568        return super(StockMove, self).unlink()
1569
1570    def _prepare_move_split_vals(self, qty):
1571        vals = {
1572            'product_uom_qty': qty,
1573            'procure_method': 'make_to_stock',
1574            'move_dest_ids': [(4, x.id) for x in self.move_dest_ids if x.state not in ('done', 'cancel')],
1575            'move_orig_ids': [(4, x.id) for x in self.move_orig_ids],
1576            'origin_returned_move_id': self.origin_returned_move_id.id,
1577            'price_unit': self.price_unit,
1578        }
1579        if self.env.context.get('force_split_uom_id'):
1580            vals['product_uom'] = self.env.context['force_split_uom_id']
1581        return vals
1582
1583    def _split(self, qty, restrict_partner_id=False):
1584        """ Splits `self` quantity and return values for a new moves to be created afterwards
1585
1586        :param qty: float. quantity to split (given in product UoM)
1587        :param restrict_partner_id: optional partner that can be given in order to force the new move to restrict its choice of quants to the ones belonging to this partner.
1588        :returns: list of dict. stock move values """
1589        self.ensure_one()
1590        if self.state in ('done', 'cancel'):
1591            raise UserError(_('You cannot split a stock move that has been set to \'Done\'.'))
1592        elif self.state == 'draft':
1593            # we restrict the split of a draft move because if not confirmed yet, it may be replaced by several other moves in
1594            # case of phantom bom (with mrp module). And we don't want to deal with this complexity by copying the product that will explode.
1595            raise UserError(_('You cannot split a draft move. It needs to be confirmed first.'))
1596        if float_is_zero(qty, precision_rounding=self.product_id.uom_id.rounding) or self.product_qty <= qty:
1597            return []
1598
1599        decimal_precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
1600
1601        # `qty` passed as argument is the quantity to backorder and is always expressed in the
1602        # quants UOM. If we're able to convert back and forth this quantity in the move's and the
1603        # quants UOM, the backordered move can keep the UOM of the move. Else, we'll create is in
1604        # the UOM of the quants.
1605        uom_qty = self.product_id.uom_id._compute_quantity(qty, self.product_uom, rounding_method='HALF-UP')
1606        if float_compare(qty, self.product_uom._compute_quantity(uom_qty, self.product_id.uom_id, rounding_method='HALF-UP'), precision_digits=decimal_precision) == 0:
1607            defaults = self._prepare_move_split_vals(uom_qty)
1608        else:
1609            defaults = self.with_context(force_split_uom_id=self.product_id.uom_id.id)._prepare_move_split_vals(qty)
1610
1611        if restrict_partner_id:
1612            defaults['restrict_partner_id'] = restrict_partner_id
1613
1614        # TDE CLEANME: remove context key + add as parameter
1615        if self.env.context.get('source_location_id'):
1616            defaults['location_id'] = self.env.context['source_location_id']
1617        new_move_vals = self.with_context(rounding_method='HALF-UP').copy_data(defaults)
1618
1619        # Update the original `product_qty` of the move. Use the general product's decimal
1620        # precision and not the move's UOM to handle case where the `quantity_done` is not
1621        # compatible with the move's UOM.
1622        new_product_qty = self.product_id.uom_id._compute_quantity(self.product_qty - qty, self.product_uom, round=False)
1623        new_product_qty = float_round(new_product_qty, precision_digits=self.env['decimal.precision'].precision_get('Product Unit of Measure'))
1624        self.with_context(do_not_unreserve=True, rounding_method='HALF-UP').write({'product_uom_qty': new_product_qty})
1625        return new_move_vals
1626
1627    def _recompute_state(self):
1628        moves_state_to_write = defaultdict(set)
1629        for move in self:
1630            if move.state in ('cancel', 'done', 'draft'):
1631                continue
1632            elif move.reserved_availability == move.product_uom_qty:
1633                moves_state_to_write['assigned'].add(move.id)
1634            elif move.reserved_availability and move.reserved_availability <= move.product_uom_qty:
1635                moves_state_to_write['partially_available'].add(move.id)
1636            elif move.procure_method == 'make_to_order' and not move.move_orig_ids:
1637                moves_state_to_write['waiting'].add(move.id)
1638            elif move.move_orig_ids and any(orig.state not in ('done', 'cancel') for orig in move.move_orig_ids):
1639                moves_state_to_write['waiting'].add(move.id)
1640            else:
1641                moves_state_to_write['confirmed'].add(move.id)
1642        for state, moves_ids in moves_state_to_write.items():
1643            self.browse(moves_ids).write({'state': state})
1644
1645    @api.model
1646    def _consuming_picking_types(self):
1647        return ['outgoing']
1648
1649    def _get_lang(self):
1650        """Determine language to use for translated description"""
1651        return self.picking_id.partner_id.lang or self.partner_id.lang or self.env.user.lang
1652
1653    def _get_source_document(self):
1654        """ Return the move's document, used by `report.stock.report_product_product_replenishment`
1655        and must be overrided to add more document type in the report.
1656        """
1657        self.ensure_one()
1658        return self.picking_id or False
1659
1660    def _get_upstream_documents_and_responsibles(self, visited):
1661        if self.move_orig_ids and any(m.state not in ('done', 'cancel') for m in self.move_orig_ids):
1662            result = set()
1663            visited |= self
1664            for move in self.move_orig_ids:
1665                if move.state not in ('done', 'cancel'):
1666                    for document, responsible, visited in move._get_upstream_documents_and_responsibles(visited):
1667                        result.add((document, responsible, visited))
1668            return result
1669        else:
1670            return [(self.picking_id, self.product_id.responsible_id, visited)]
1671
1672    def _set_quantity_done_prepare_vals(self, qty):
1673        res = []
1674        for ml in self.move_line_ids:
1675            ml_qty = ml.product_uom_qty - ml.qty_done
1676            if float_compare(ml_qty, 0, precision_rounding=ml.product_uom_id.rounding) <= 0:
1677                continue
1678            # Convert move line qty into move uom
1679            if ml.product_uom_id != self.product_uom:
1680                ml_qty = ml.product_uom_id._compute_quantity(ml_qty, self.product_uom, round=False)
1681
1682            taken_qty = min(qty, ml_qty)
1683            # Convert taken qty into move line uom
1684            if ml.product_uom_id != self.product_uom:
1685                taken_qty = self.product_uom._compute_quantity(ml_qty, ml.product_uom_id, round=False)
1686
1687            # Assign qty_done and explicitly round to make sure there is no inconsistency between
1688            # ml.qty_done and qty.
1689            taken_qty = float_round(taken_qty, precision_rounding=ml.product_uom_id.rounding)
1690            res.append((1, ml.id, {'qty_done': ml.qty_done + taken_qty}))
1691            if ml.product_uom_id != self.product_uom:
1692                taken_qty = ml.product_uom_id._compute_quantity(ml_qty, self.product_uom, round=False)
1693            qty -= taken_qty
1694
1695            if float_compare(qty, 0.0, precision_rounding=self.product_uom.rounding) <= 0:
1696                break
1697
1698        for ml in self.move_line_ids:
1699            if float_is_zero(ml.product_uom_qty, precision_rounding=ml.product_uom_id.rounding) and float_is_zero(ml.qty_done, precision_rounding=ml.product_uom_id.rounding):
1700                res.append((2, ml.id))
1701
1702        if float_compare(qty, 0.0, precision_rounding=self.product_uom.rounding) > 0:
1703            if self.product_id.tracking != 'serial':
1704                vals = self._prepare_move_line_vals(quantity=0)
1705                vals['qty_done'] = qty
1706                res.append((0, 0, vals))
1707            else:
1708                uom_qty = self.product_uom._compute_quantity(qty, self.product_id.uom_id)
1709                for i in range(0, int(uom_qty)):
1710                    vals = self._prepare_move_line_vals(quantity=0)
1711                    vals['qty_done'] = 1
1712                    vals['product_uom_id'] = self.product_id.uom_id.id
1713                    res.append((0, 0, vals))
1714        return res
1715
1716    def _set_quantity_done(self, qty):
1717        """
1718        Set the given quantity as quantity done on the move through the move lines. The method is
1719        able to handle move lines with a different UoM than the move (but honestly, this would be
1720        looking for trouble...).
1721        @param qty: quantity in the UoM of move.product_uom
1722        """
1723        self.move_line_ids = self._set_quantity_done_prepare_vals(qty)
1724
1725    def _adjust_procure_method(self):
1726        """ This method will try to apply the procure method MTO on some moves if
1727        a compatible MTO route is found. Else the procure method will be set to MTS
1728        """
1729        # Prepare the MTSO variables. They are needed since MTSO moves are handled separately.
1730        # We need 2 dicts:
1731        # - needed quantity per location per product
1732        # - forecasted quantity per location per product
1733        mtso_products_by_locations = defaultdict(list)
1734        mtso_needed_qties_by_loc = defaultdict(dict)
1735        mtso_free_qties_by_loc = {}
1736        mtso_moves = self.env['stock.move']
1737
1738        for move in self:
1739            product_id = move.product_id
1740            domain = [
1741                ('location_src_id', '=', move.location_id.id),
1742                ('location_id', '=', move.location_dest_id.id),
1743                ('action', '!=', 'push')
1744            ]
1745            rules = self.env['procurement.group']._search_rule(False, product_id, move.warehouse_id, domain)
1746            if rules:
1747                if rules.procure_method in ['make_to_order', 'make_to_stock']:
1748                    move.procure_method = rules.procure_method
1749                else:
1750                    # Get the needed quantity for the `mts_else_mto` moves.
1751                    mtso_needed_qties_by_loc[rules.location_src_id].setdefault(product_id.id, 0)
1752                    mtso_needed_qties_by_loc[rules.location_src_id][product_id.id] += move.product_qty
1753
1754                    # This allow us to get the forecasted quantity in batch later on
1755                    mtso_products_by_locations[rules.location_src_id].append(product_id.id)
1756                    mtso_moves |= move
1757            else:
1758                move.procure_method = 'make_to_stock'
1759
1760        # Get the forecasted quantity for the `mts_else_mto` moves.
1761        for location, product_ids in mtso_products_by_locations.items():
1762            products = self.env['product.product'].browse(product_ids).with_context(location=location.id)
1763            mtso_free_qties_by_loc[location] = {product.id: product.free_qty for product in products}
1764
1765        # Now that we have the needed and forecasted quantity per location and per product, we can
1766        # choose whether the mtso_moves need to be MTO or MTS.
1767        for move in mtso_moves:
1768            needed_qty = move.product_qty
1769            forecasted_qty = mtso_free_qties_by_loc[move.location_id][move.product_id.id]
1770            if float_compare(needed_qty, forecasted_qty, precision_rounding=product_id.uom_id.rounding) <= 0:
1771                move.procure_method = 'make_to_stock'
1772                mtso_free_qties_by_loc[move.location_id][move.product_id.id] -= needed_qty
1773            else:
1774                move.procure_method = 'make_to_order'
1775
1776    def _show_details_in_draft(self):
1777        self.ensure_one()
1778        return self.state != 'draft' or (self.picking_id.immediate_transfer and self.state == 'draft')
1779
1780    def _trigger_scheduler(self):
1781        """ Check for auto-triggered orderpoints and trigger them. """
1782        if not self or self.env['ir.config_parameter'].sudo().get_param('stock.no_auto_scheduler'):
1783            return
1784
1785        orderpoints_by_company = defaultdict(lambda: self.env['stock.warehouse.orderpoint'])
1786        for move in self:
1787            orderpoint = self.env['stock.warehouse.orderpoint'].search([
1788                ('product_id', '=', move.product_id.id),
1789                ('trigger', '=', 'auto'),
1790                ('location_id', 'parent_of', move.location_id.id),
1791                ('company_id', '=', move.company_id.id)
1792            ], limit=1)
1793            if orderpoint:
1794                orderpoints_by_company[orderpoint.company_id] |= orderpoint
1795        for company, orderpoints in orderpoints_by_company.items():
1796            orderpoints._procure_orderpoint_confirm(company_id=company, raise_user_error=False)
1797
1798    def _trigger_assign(self):
1799        """ Check for and trigger action_assign for confirmed/partially_available moves related to done moves.
1800            Disable auto reservation if user configured to do so.
1801        """
1802        if not self or self.env['ir.config_parameter'].sudo().get_param('stock.picking_no_auto_reserve'):
1803            return
1804
1805        domains = []
1806        for move in self:
1807            domains.append([('product_id', '=', move.product_id.id), ('location_id', '=', move.location_dest_id.id)])
1808        static_domain = [('state', 'in', ['confirmed', 'partially_available']), ('procure_method', '=', 'make_to_stock')]
1809        moves_to_reserve = self.env['stock.move'].search(expression.AND([static_domain, expression.OR(domains)]))
1810        moves_to_reserve._action_assign()
1811