diff options
Diffstat (limited to 'addons/stock/models/stock_move.py')
| -rw-r--r-- | addons/stock/models/stock_move.py | 1808 |
1 files changed, 1808 insertions, 0 deletions
diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py new file mode 100644 index 00000000..af37e002 --- /dev/null +++ b/addons/stock/models/stock_move.py @@ -0,0 +1,1808 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +from collections import defaultdict +from datetime import datetime +from itertools import groupby +from operator import itemgetter +from re import findall as regex_findall +from re import split as regex_split + +from dateutil import relativedelta + +from odoo import SUPERUSER_ID, _, api, fields, models +from odoo.exceptions import UserError +from odoo.osv import expression +from odoo.tools.float_utils import float_compare, float_is_zero, float_repr, float_round +from odoo.tools.misc import format_date, OrderedSet + +PROCUREMENT_PRIORITIES = [('0', 'Normal'), ('1', 'Urgent')] + + +class StockMove(models.Model): + _name = "stock.move" + _description = "Stock Move" + _order = 'sequence, id' + + def _default_group_id(self): + if self.env.context.get('default_picking_id'): + return self.env['stock.picking'].browse(self.env.context['default_picking_id']).group_id.id + return False + + name = fields.Char('Description', index=True, required=True) + sequence = fields.Integer('Sequence', default=10) + priority = fields.Selection( + PROCUREMENT_PRIORITIES, 'Priority', default='0', + compute="_compute_priority", store=True, index=True) + create_date = fields.Datetime('Creation Date', index=True, readonly=True) + date = fields.Datetime( + 'Date Scheduled', default=fields.Datetime.now, index=True, required=True, + help="Scheduled date until move is done, then date of actual move processing") + date_deadline = fields.Datetime( + "Deadline", readonly=True, + help="Date Promise to the customer on the top level document (SO/PO)") + company_id = fields.Many2one( + 'res.company', 'Company', + default=lambda self: self.env.company, + index=True, required=True) + product_id = fields.Many2one( + 'product.product', 'Product', + check_company=True, + domain="[('type', 'in', ['product', 'consu']), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", index=True, required=True, + states={'done': [('readonly', True)]}) + description_picking = fields.Text('Description of Picking') + product_qty = fields.Float( + 'Real Quantity', compute='_compute_product_qty', inverse='_set_product_qty', + digits=0, store=True, compute_sudo=True, + help='Quantity in the default UoM of the product') + product_uom_qty = fields.Float( + 'Demand', + digits='Product Unit of Measure', + default=0.0, required=True, states={'done': [('readonly', True)]}, + help="This is the quantity of products from an inventory " + "point of view. For moves in the state 'done', this is the " + "quantity of products that were actually moved. For other " + "moves, this is the quantity of product that is planned to " + "be moved. Lowering this quantity does not generate a " + "backorder. Changing this quantity on assigned moves affects " + "the product reservation, and should be done with care.") + product_uom = fields.Many2one('uom.uom', 'Unit of Measure', required=True, domain="[('category_id', '=', product_uom_category_id)]") + product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id') + # TDE FIXME: make it stored, otherwise group will not work + product_tmpl_id = fields.Many2one( + 'product.template', 'Product Template', + related='product_id.product_tmpl_id', readonly=False, + help="Technical: used in views") + location_id = fields.Many2one( + 'stock.location', 'Source Location', + auto_join=True, index=True, required=True, + check_company=True, + help="Sets a location if you produce at a fixed location. This can be a partner location if you subcontract the manufacturing operations.") + location_dest_id = fields.Many2one( + 'stock.location', 'Destination Location', + auto_join=True, index=True, required=True, + check_company=True, + help="Location where the system will stock the finished products.") + partner_id = fields.Many2one( + 'res.partner', 'Destination Address ', + states={'done': [('readonly', True)]}, + help="Optional address where goods are to be delivered, specifically used for allotment") + move_dest_ids = fields.Many2many( + 'stock.move', 'stock_move_move_rel', 'move_orig_id', 'move_dest_id', 'Destination Moves', + copy=False, + help="Optional: next stock move when chaining them") + move_orig_ids = fields.Many2many( + 'stock.move', 'stock_move_move_rel', 'move_dest_id', 'move_orig_id', 'Original Move', + copy=False, + help="Optional: previous stock move when chaining them") + picking_id = fields.Many2one('stock.picking', 'Transfer', index=True, states={'done': [('readonly', True)]}, check_company=True) + picking_partner_id = fields.Many2one('res.partner', 'Transfer Destination Address', related='picking_id.partner_id', readonly=False) + note = fields.Text('Notes') + state = fields.Selection([ + ('draft', 'New'), ('cancel', 'Cancelled'), + ('waiting', 'Waiting Another Move'), + ('confirmed', 'Waiting Availability'), + ('partially_available', 'Partially Available'), + ('assigned', 'Available'), + ('done', 'Done')], string='Status', + copy=False, default='draft', index=True, readonly=True, + help="* New: When the stock move is created and not yet confirmed.\n" + "* Waiting Another Move: This state can be seen when a move is waiting for another one, for example in a chained flow.\n" + "* 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" + "* Available: When products are reserved, it is set to \'Available\'.\n" + "* Done: When the shipment is processed, the state is \'Done\'.") + price_unit = fields.Float( + 'Unit Price', help="Technical field used to record the product cost set by the user during a picking confirmation (when costing " + "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 + backorder_id = fields.Many2one('stock.picking', 'Back Order of', related='picking_id.backorder_id', index=True, readonly=False) + origin = fields.Char("Source Document") + procure_method = fields.Selection([ + ('make_to_stock', 'Default: Take From Stock'), + ('make_to_order', 'Advanced: Apply Procurement Rules')], string='Supply Method', + default='make_to_stock', required=True, + help="By default, the system will take from the stock in the source location and passively wait for availability. " + "The other possibility allows you to directly create a procurement on the source location (and thus ignore " + "its current stock) to gather products. If we want to chain moves and have this one to wait for the previous, " + "this second option should be chosen.") + scrapped = fields.Boolean('Scrapped', related='location_dest_id.scrap_location', readonly=True, store=True) + scrap_ids = fields.One2many('stock.scrap', 'move_id') + group_id = fields.Many2one('procurement.group', 'Procurement Group', default=_default_group_id) + rule_id = fields.Many2one( + 'stock.rule', 'Stock Rule', ondelete='restrict', help='The stock rule that created this stock move', + check_company=True) + propagate_cancel = fields.Boolean( + 'Propagate cancel and split', default=True, + help='If checked, when this move is cancelled, cancel the linked move too') + delay_alert_date = fields.Datetime('Delay Alert Date', help='Process at this date to be on time', compute="_compute_delay_alert_date", store=True) + picking_type_id = fields.Many2one('stock.picking.type', 'Operation Type', check_company=True) + inventory_id = fields.Many2one('stock.inventory', 'Inventory', check_company=True) + move_line_ids = fields.One2many('stock.move.line', 'move_id') + move_line_nosuggest_ids = fields.One2many('stock.move.line', 'move_id', domain=['|', ('product_qty', '=', 0.0), ('qty_done', '!=', 0.0)]) + origin_returned_move_id = fields.Many2one( + 'stock.move', 'Origin return move', copy=False, index=True, + help='Move that created the return move', check_company=True) + returned_move_ids = fields.One2many('stock.move', 'origin_returned_move_id', 'All returned moves', help='Optional: all returned moves created from this move') + reserved_availability = fields.Float( + 'Quantity Reserved', compute='_compute_reserved_availability', + digits='Product Unit of Measure', + readonly=True, help='Quantity that has already been reserved for this move') + availability = fields.Float( + 'Forecasted Quantity', compute='_compute_product_availability', + readonly=True, help='Quantity in stock that can still be reserved for this move') + restrict_partner_id = fields.Many2one( + 'res.partner', 'Owner ', help="Technical field used to depict a restriction on the ownership of quants to consider when marking this move as 'done'", + check_company=True) + route_ids = fields.Many2many( + 'stock.location.route', 'stock_location_route_move', 'move_id', 'route_id', 'Destination route', help="Preferred route", + check_company=True) + 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).") + has_tracking = fields.Selection(related='product_id.tracking', string='Product with Tracking') + quantity_done = fields.Float('Quantity Done', compute='_quantity_done_compute', digits='Product Unit of Measure', inverse='_quantity_done_set') + show_operations = fields.Boolean(related='picking_id.picking_type_id.show_operations', readonly=False) + show_details_visible = fields.Boolean('Details Visible', compute='_compute_show_details_visible') + show_reserved_availability = fields.Boolean('From Supplier', compute='_compute_show_reserved_availability') + picking_code = fields.Selection(related='picking_id.picking_type_id.code', readonly=True) + product_type = fields.Selection(related='product_id.type', readonly=True) + additional = fields.Boolean("Whether the move was added after the picking's confirmation", default=False) + is_locked = fields.Boolean(compute='_compute_is_locked', readonly=True) + is_initial_demand_editable = fields.Boolean('Is initial demand editable', compute='_compute_is_initial_demand_editable') + is_quantity_done_editable = fields.Boolean('Is quantity done editable', compute='_compute_is_quantity_done_editable') + reference = fields.Char(compute='_compute_reference', string="Reference", store=True) + has_move_lines = fields.Boolean(compute='_compute_has_move_lines') + package_level_id = fields.Many2one('stock.package_level', 'Package Level', check_company=True, copy=False) + picking_type_entire_packs = fields.Boolean(related='picking_type_id.show_entire_packs', readonly=True) + display_assign_serial = fields.Boolean(compute='_compute_display_assign_serial') + next_serial = fields.Char('First SN') + next_serial_count = fields.Integer('Number of SN') + orderpoint_id = fields.Many2one('stock.warehouse.orderpoint', 'Original Reordering Rule', check_company=True) + forecast_availability = fields.Float('Forecast Availability', compute='_compute_forecast_information', digits='Product Unit of Measure', compute_sudo=True) + forecast_expected_date = fields.Datetime('Forecasted Expected date', compute='_compute_forecast_information', compute_sudo=True) + lot_ids = fields.Many2many('stock.production.lot', compute='_compute_lot_ids', inverse='_set_lot_ids', string='Serial Numbers', readonly=False) + + @api.onchange('product_id', 'picking_type_id') + def onchange_product(self): + if self.product_id: + product = self.product_id.with_context(lang=self._get_lang()) + self.description_picking = product._get_description(self.picking_type_id) + + @api.depends('has_tracking', 'picking_type_id.use_create_lots', 'picking_type_id.use_existing_lots', 'state') + def _compute_display_assign_serial(self): + for move in self: + move.display_assign_serial = ( + move.has_tracking == 'serial' and + move.state in ('partially_available', 'assigned', 'confirmed') and + move.picking_type_id.use_create_lots and + not move.picking_type_id.use_existing_lots + and not move.origin_returned_move_id.id + ) + + @api.depends('picking_id.priority') + def _compute_priority(self): + for move in self: + move.priority = move.picking_id.priority or '0' + + @api.depends('picking_id.is_locked') + def _compute_is_locked(self): + for move in self: + if move.picking_id: + move.is_locked = move.picking_id.is_locked + else: + move.is_locked = False + + @api.depends('product_id', 'has_tracking', 'move_line_ids') + def _compute_show_details_visible(self): + """ According to this field, the button that calls `action_show_details` will be displayed + to work on a move from its picking form view, or not. + """ + has_package = self.user_has_groups('stock.group_tracking_lot') + multi_locations_enabled = self.user_has_groups('stock.group_stock_multi_locations') + consignment_enabled = self.user_has_groups('stock.group_tracking_owner') + + show_details_visible = multi_locations_enabled or has_package + + for move in self: + if not move.product_id: + move.show_details_visible = False + elif len(move.move_line_ids) > 1: + move.show_details_visible = True + else: + move.show_details_visible = (((consignment_enabled and move.picking_id.picking_type_id.code != 'incoming') or + show_details_visible or move.has_tracking != 'none') and + move._show_details_in_draft() and + move.picking_id.picking_type_id.show_operations is False) + + def _compute_show_reserved_availability(self): + """ This field is only of use in an attrs in the picking view, in order to hide the + "available" column if the move is coming from a supplier. + """ + for move in self: + move.show_reserved_availability = not move.location_id.usage == 'supplier' + + @api.depends('state', 'picking_id') + def _compute_is_initial_demand_editable(self): + for move in self: + if not move.picking_id.immediate_transfer and move.state == 'draft': + move.is_initial_demand_editable = True + elif not move.picking_id.is_locked and move.state != 'done' and move.picking_id: + move.is_initial_demand_editable = True + else: + move.is_initial_demand_editable = False + + @api.depends('state', 'picking_id', 'product_id') + def _compute_is_quantity_done_editable(self): + for move in self: + if not move.product_id: + move.is_quantity_done_editable = False + elif not move.picking_id.immediate_transfer and move.picking_id.state == 'draft': + move.is_quantity_done_editable = False + elif move.picking_id.is_locked and move.state in ('done', 'cancel'): + move.is_quantity_done_editable = False + elif move.show_details_visible: + move.is_quantity_done_editable = False + elif move.show_operations: + move.is_quantity_done_editable = False + else: + move.is_quantity_done_editable = True + + @api.depends('picking_id', 'name') + def _compute_reference(self): + for move in self: + move.reference = move.picking_id.name if move.picking_id else move.name + + @api.depends('move_line_ids') + def _compute_has_move_lines(self): + for move in self: + move.has_move_lines = bool(move.move_line_ids) + + @api.depends('product_id', 'product_uom', 'product_uom_qty') + def _compute_product_qty(self): + # DLE FIXME: `stock/tests/test_move2.py` + # `product_qty` is a STORED compute field which depends on the context :/ + # I asked SLE to change this, task: 2041971 + # In the mean time I cheat and force the rouding to half-up, it seems it works for all tests. + rounding_method = 'HALF-UP' + for move in self: + move.product_qty = move.product_uom._compute_quantity( + move.product_uom_qty, move.product_id.uom_id, rounding_method=rounding_method) + + def _get_move_lines(self): + """ This will return the move lines to consider when applying _quantity_done_compute on a stock.move. + In some context, such as MRP, it is necessary to compute quantity_done on filtered sock.move.line.""" + self.ensure_one() + if self.picking_type_id.show_reserved is False: + return self.move_line_nosuggest_ids + return self.move_line_ids + + @api.depends('move_orig_ids.date', 'move_orig_ids.state', 'state', 'date') + def _compute_delay_alert_date(self): + for move in self: + if move.state in ('done', 'cancel'): + move.delay_alert_date = False + continue + prev_moves = move.move_orig_ids.filtered(lambda m: m.state not in ('done', 'cancel') and m.date) + prev_max_date = max(prev_moves.mapped("date"), default=False) + if prev_max_date and prev_max_date > move.date: + move.delay_alert_date = prev_max_date + else: + move.delay_alert_date = False + + @api.depends('move_line_ids.qty_done', 'move_line_ids.product_uom_id', 'move_line_nosuggest_ids.qty_done', 'picking_type_id') + def _quantity_done_compute(self): + """ This field represents the sum of the move lines `qty_done`. It allows the user to know + if there is still work to do. + + We take care of rounding this value at the general decimal precision and not the rounding + of the move's UOM to make sure this value is really close to the real sum, because this + field will be used in `_action_done` in order to know if the move will need a backorder or + an extra move. + """ + if not any(self._ids): + # onchange + for move in self: + quantity_done = 0 + for move_line in move._get_move_lines(): + quantity_done += move_line.product_uom_id._compute_quantity( + move_line.qty_done, move.product_uom, round=False) + move.quantity_done = quantity_done + else: + # compute + move_lines_ids = set() + for move in self: + move_lines_ids |= set(move._get_move_lines().ids) + + data = self.env['stock.move.line'].read_group( + [('id', 'in', list(move_lines_ids))], + ['move_id', 'product_uom_id', 'qty_done'], ['move_id', 'product_uom_id'], + lazy=False + ) + + rec = defaultdict(list) + for d in data: + rec[d['move_id'][0]] += [(d['product_uom_id'][0], d['qty_done'])] + + for move in self: + uom = move.product_uom + move.quantity_done = sum( + self.env['uom.uom'].browse(line_uom_id)._compute_quantity(qty, uom, round=False) + for line_uom_id, qty in rec.get(move.ids[0] if move.ids else move.id, []) + ) + + def _quantity_done_set(self): + quantity_done = self[0].quantity_done # any call to create will invalidate `move.quantity_done` + for move in self: + move_lines = move._get_move_lines() + if not move_lines: + if quantity_done: + # do not impact reservation here + move_line = self.env['stock.move.line'].create(dict(move._prepare_move_line_vals(), qty_done=quantity_done)) + move.write({'move_line_ids': [(4, move_line.id)]}) + elif len(move_lines) == 1: + move_lines[0].qty_done = quantity_done + else: + # Bypass the error if we're trying to write the same value. + ml_quantity_done = 0 + for move_line in move_lines: + ml_quantity_done += move_line.product_uom_id._compute_quantity(move_line.qty_done, move.product_uom, round=False) + if float_compare(quantity_done, ml_quantity_done, precision_rounding=move.product_uom.rounding) != 0: + raise UserError(_("Cannot set the done quantity from this stock move, work directly with the move lines.")) + + def _set_product_qty(self): + """ The meaning of product_qty field changed lately and is now a functional field computing the quantity + in the default product UoM. This code has been added to raise an error if a write is made given a value + for `product_qty`, where the same write should set the `product_uom_qty` field instead, in order to + detect errors. """ + raise UserError(_('The requested operation cannot be processed because of a programming error setting the `product_qty` field instead of the `product_uom_qty`.')) + + @api.depends('move_line_ids.product_qty') + def _compute_reserved_availability(self): + """ Fill the `availability` field on a stock move, which is the actual reserved quantity + and is represented by the aggregated `product_qty` on the linked move lines. If the move + is force assigned, the value will be 0. + """ + if not any(self._ids): + # onchange + for move in self: + reserved_availability = sum(move.move_line_ids.mapped('product_qty')) + move.reserved_availability = move.product_id.uom_id._compute_quantity( + reserved_availability, move.product_uom, rounding_method='HALF-UP') + else: + # compute + result = {data['move_id'][0]: data['product_qty'] for data in + self.env['stock.move.line'].read_group([('move_id', 'in', self.ids)], ['move_id', 'product_qty'], ['move_id'])} + for move in self: + move.reserved_availability = move.product_id.uom_id._compute_quantity( + result.get(move.id, 0.0), move.product_uom, rounding_method='HALF-UP') + + @api.depends('state', 'product_id', 'product_qty', 'location_id') + def _compute_product_availability(self): + """ Fill the `availability` field on a stock move, which is the quantity to potentially + reserve. When the move is done, `availability` is set to the quantity the move did actually + move. + """ + for move in self: + if move.state == 'done': + move.availability = move.product_qty + else: + total_availability = self.env['stock.quant']._get_available_quantity(move.product_id, move.location_id) if move.product_id else 0.0 + move.availability = min(move.product_qty, total_availability) + + @api.depends('product_id', 'picking_type_id', 'picking_id', 'reserved_availability', 'priority', 'state', 'product_uom_qty', 'location_id') + def _compute_forecast_information(self): + """ Compute forecasted information of the related product by warehouse.""" + self.forecast_availability = False + self.forecast_expected_date = False + + not_product_moves = self.filtered(lambda move: move.product_id.type != 'product') + for move in not_product_moves: + move.forecast_availability = move.product_qty + + product_moves = (self - not_product_moves) + warehouse_by_location = {loc: loc.get_warehouse() for loc in product_moves.location_id} + + outgoing_unreserved_moves_per_warehouse = defaultdict(lambda: self.env['stock.move']) + for move in product_moves: + picking_type = move.picking_type_id or move.picking_id.picking_type_id + is_unreserved = move.state in ('waiting', 'confirmed', 'partially_available') + if picking_type.code in self._consuming_picking_types() and is_unreserved: + outgoing_unreserved_moves_per_warehouse[warehouse_by_location[move.location_id]] |= move + elif picking_type.code in self._consuming_picking_types(): + move.forecast_availability = move.product_uom._compute_quantity( + move.reserved_availability, move.product_id.uom_id, rounding_method='HALF-UP') + + for warehouse, moves in outgoing_unreserved_moves_per_warehouse.items(): + if not warehouse: # No prediction possible if no warehouse. + continue + product_variant_ids = moves.product_id.ids + wh_location_ids = [loc['id'] for loc in self.env['stock.location'].search_read( + [('id', 'child_of', warehouse.view_location_id.id)], + ['id'], + )] + ForecastedReport = self.env['report.stock.report_product_product_replenishment'] + forecast_lines = ForecastedReport.with_context(warehouse=warehouse.id)._get_report_lines(None, product_variant_ids, wh_location_ids) + for move in moves: + lines = [l for l in forecast_lines if l["move_out"] == move._origin and l["replenishment_filled"] is True] + if lines: + move.forecast_availability = sum(m['quantity'] for m in lines) + move_ins_lines = list(filter(lambda report_line: report_line['move_in'], lines)) + if move_ins_lines: + expected_date = max(m['move_in'].date for m in move_ins_lines) + move.forecast_expected_date = expected_date + + def _set_date_deadline(self, new_deadline): + # Handle the propagation of `date_deadline` fields (up and down stream - only update by up/downstream documents) + already_propagate_ids = self.env.context.get('date_deadline_propagate_ids', set()) | set(self.ids) + self = self.with_context(date_deadline_propagate_ids=already_propagate_ids) + for move in self: + moves_to_update = (move.move_dest_ids | move.move_orig_ids) + if move.date_deadline: + delta = move.date_deadline - fields.Datetime.to_datetime(new_deadline) + else: + delta = 0 + for move_update in moves_to_update: + if move_update.state in ('done', 'cancel'): + continue + if move_update.id in already_propagate_ids: + continue + if move_update.date_deadline and delta: + move_update.date_deadline -= delta + else: + move_update.date_deadline = new_deadline + + @api.depends('move_line_ids', 'move_line_ids.lot_id', 'move_line_ids.qty_done') + def _compute_lot_ids(self): + domain_nosuggest = [('move_id', 'in', self.ids), ('lot_id', '!=', False), '|', ('qty_done', '!=', 0.0), ('product_qty', '=', 0.0)] + domain_suggest = [('move_id', 'in', self.ids), ('lot_id', '!=', False), ('qty_done', '!=', 0.0)] + lots_by_move_id_list = [] + for domain in [domain_nosuggest, domain_suggest]: + lots_by_move_id = self.env['stock.move.line'].read_group( + domain, + ['move_id', 'lot_ids:array_agg(lot_id)'], ['move_id'], + ) + lots_by_move_id_list.append({by_move['move_id'][0]: by_move['lot_ids'] for by_move in lots_by_move_id}) + for move in self: + move.lot_ids = lots_by_move_id_list[0 if move.picking_type_id.show_reserved else 1].get(move._origin.id, []) + + def _set_lot_ids(self): + for move in self: + move_lines_commands = [] + if move.picking_type_id.show_reserved is False: + mls = move.move_line_nosuggest_ids + else: + mls = move.move_line_ids + mls = mls.filtered(lambda ml: ml.lot_id) + for ml in mls: + if ml.qty_done and ml.lot_id not in move.lot_ids: + move_lines_commands.append((2, ml.id)) + ls = move.move_line_ids.lot_id + for lot in move.lot_ids: + if lot not in ls: + move_line_vals = self._prepare_move_line_vals(quantity=0) + move_line_vals['lot_id'] = lot.id + move_line_vals['lot_name'] = lot.name + move_line_vals['product_uom_id'] = move.product_id.uom_id.id + move_line_vals['qty_done'] = 1 + move_lines_commands.append((0, 0, move_line_vals)) + move.write({'move_line_ids': move_lines_commands}) + + @api.constrains('product_uom') + def _check_uom(self): + moves_error = self.filtered(lambda move: move.product_id.uom_id.category_id != move.product_uom.category_id) + if moves_error: + user_warning = _('You cannot perform the move because the unit of measure has a different category as the product unit of measure.') + for move in moves_error: + 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) + user_warning += _('\n\nBlocking: %s') % ' ,'.join(moves_error.mapped('name')) + raise UserError(user_warning) + + def init(self): + self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('stock_move_product_location_index',)) + if not self._cr.fetchone(): + self._cr.execute('CREATE INDEX stock_move_product_location_index ON stock_move (product_id, location_id, location_dest_id, company_id, state)') + + @api.model + def default_get(self, fields_list): + # We override the default_get to make stock moves created after the picking was confirmed + # directly as available in immediate transfer mode. This allows to create extra move lines + # in the fp view. In planned transfer, the stock move are marked as `additional` and will be + # auto-confirmed. + defaults = super(StockMove, self).default_get(fields_list) + if self.env.context.get('default_picking_id'): + picking_id = self.env['stock.picking'].browse(self.env.context['default_picking_id']) + if picking_id.state == 'done': + defaults['state'] = 'done' + defaults['product_uom_qty'] = 0.0 + defaults['additional'] = True + elif picking_id.state not in ['cancel', 'draft', 'done']: + if picking_id.immediate_transfer: + defaults['state'] = 'assigned' + defaults['product_uom_qty'] = 0.0 + defaults['additional'] = True # to trigger `_autoconfirm_picking` + return defaults + + def name_get(self): + res = [] + for move in self: + res.append((move.id, '%s%s%s>%s' % ( + move.picking_id.origin and '%s/' % move.picking_id.origin or '', + move.product_id.code and '%s: ' % move.product_id.code or '', + move.location_id.name, move.location_dest_id.name))) + return res + + def write(self, vals): + # Handle the write on the initial demand by updating the reserved quantity and logging + # messages according to the state of the stock.move records. + receipt_moves_to_reassign = self.env['stock.move'] + move_to_recompute_state = self.env['stock.move'] + if 'product_uom_qty' in vals: + move_to_unreserve = self.env['stock.move'] + for move in self.filtered(lambda m: m.state not in ('done', 'draft') and m.picking_id): + if float_compare(vals['product_uom_qty'], move.product_uom_qty, precision_rounding=move.product_uom.rounding): + self.env['stock.move.line']._log_message(move.picking_id, move, 'stock.track_move_template', vals) + if self.env.context.get('do_not_unreserve') is None: + move_to_unreserve = self.filtered( + 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 + ) + move_to_unreserve._do_unreserve() + (self - move_to_unreserve).filtered(lambda m: m.state == 'assigned').write({'state': 'partially_available'}) + # When editing the initial demand, directly run again action assign on receipt moves. + receipt_moves_to_reassign |= move_to_unreserve.filtered(lambda m: m.location_id.usage == 'supplier') + receipt_moves_to_reassign |= (self - move_to_unreserve).filtered(lambda m: m.location_id.usage == 'supplier' and m.state in ('partially_available', 'assigned')) + move_to_recompute_state |= self - move_to_unreserve - receipt_moves_to_reassign + if 'date_deadline' in vals: + self._set_date_deadline(vals.get('date_deadline')) + res = super(StockMove, self).write(vals) + if move_to_recompute_state: + move_to_recompute_state._recompute_state() + if receipt_moves_to_reassign: + receipt_moves_to_reassign._action_assign() + return res + + def _delay_alert_get_documents(self): + """Returns a list of recordset of the documents linked to the stock.move in `self` in order + to post the delay alert next activity. These documents are deduplicated. This method is meant + to be overridden by other modules, each of them adding an element by type of recordset on + this list. + + :return: a list of recordset of the documents linked to `self` + :rtype: list + """ + return list(self.mapped('picking_id')) + + def _propagate_date_log_note(self, move_orig): + """Post a deadline change alert log note on the documents linked to `self`.""" + # TODO : get the end document (PO/SO/MO) + doc_orig = move_orig._delay_alert_get_documents() + documents = self._delay_alert_get_documents() + if not documents or not doc_orig: + return + + 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) + msg_subject = _("Deadline updated due to delay on %s", doc_orig[0].name) + # write the message on each document + for doc in documents: + last_message = doc.message_ids[:1] + # Avoids to write the exact same message multiple times. + if last_message and last_message.subject == msg_subject: + continue + odoobot_id = self.env['ir.model.data'].xmlid_to_res_id("base.partner_root") + doc.message_post(body=msg, author_id=odoobot_id, subject=msg_subject) + + def action_show_details(self): + """ Returns an action that will open a form view (in a popup) allowing to work on all the + move lines of a particular move. This form view is used when "show operations" is not + checked on the picking type. + """ + self.ensure_one() + + picking_type_id = self.picking_type_id or self.picking_id.picking_type_id + + # If "show suggestions" is not checked on the picking type, we have to filter out the + # reserved move lines. We do this by displaying `move_line_nosuggest_ids`. We use + # different views to display one field or another so that the webclient doesn't have to + # fetch both. + if picking_type_id.show_reserved: + view = self.env.ref('stock.view_stock_move_operations') + else: + view = self.env.ref('stock.view_stock_move_nosuggest_operations') + + return { + 'name': _('Detailed Operations'), + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'stock.move', + 'views': [(view.id, 'form')], + 'view_id': view.id, + 'target': 'new', + 'res_id': self.id, + 'context': dict( + self.env.context, + show_owner=self.picking_type_id.code != 'incoming', + 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`. + 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, + show_source_location=self.picking_type_id.code != 'incoming', + show_destination_location=self.picking_type_id.code != 'outgoing', + show_package=not self.location_id.usage == 'supplier', + show_reserved_quantity=self.state != 'done' and not self.picking_id.immediate_transfer and self.picking_type_id.code != 'incoming' + ), + } + + def action_assign_serial_show_details(self): + """ On `self.move_line_ids`, assign `lot_name` according to + `self.next_serial` before returning `self.action_show_details`. + """ + self.ensure_one() + if not self.next_serial: + raise UserError(_("You need to set a Serial Number before generating more.")) + self._generate_serial_numbers() + return self.action_show_details() + + def action_clear_lines_show_details(self): + """ Unlink `self.move_line_ids` before returning `self.action_show_details`. + Useful for if a user creates too many SNs by accident via action_assign_serial_show_details + since there's no way to undo the action. + """ + self.ensure_one() + if self.picking_type_id.show_reserved: + move_lines = self.move_line_ids + else: + move_lines = self.move_line_nosuggest_ids + move_lines.unlink() + return self.action_show_details() + + def action_assign_serial(self): + """ Opens a wizard to assign SN's name on each move lines. + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id("stock.act_assign_serial_numbers") + action['context'] = { + 'default_product_id': self.product_id.id, + 'default_move_id': self.id, + } + return action + + def action_product_forecast_report(self): + self.ensure_one() + action = self.product_id.action_product_forecast_report() + warehouse = self.location_id.get_warehouse() + action['context'] = {'warehouse': warehouse.id, } if warehouse else {} + return action + + def _do_unreserve(self): + moves_to_unreserve = OrderedSet() + for move in self: + if move.state == 'cancel' or (move.state == 'done' and move.scrapped): + # We may have cancelled move in an open picking in a "propagate_cancel" scenario. + # We may have done move in an open picking in a scrap scenario. + continue + elif move.state == 'done': + raise UserError(_("You cannot unreserve a stock move that has been set to 'Done'.")) + moves_to_unreserve.add(move.id) + moves_to_unreserve = self.env['stock.move'].browse(moves_to_unreserve) + + ml_to_update, ml_to_unlink = OrderedSet(), OrderedSet() + moves_not_to_recompute = OrderedSet() + for ml in moves_to_unreserve.move_line_ids: + if ml.qty_done: + ml_to_update.add(ml.id) + else: + ml_to_unlink.add(ml.id) + moves_not_to_recompute.add(ml.move_id.id) + ml_to_update, ml_to_unlink = self.env['stock.move.line'].browse(ml_to_update), self.env['stock.move.line'].browse(ml_to_unlink) + moves_not_to_recompute = self.env['stock.move'].browse(moves_not_to_recompute) + + ml_to_update.write({'product_uom_qty': 0}) + ml_to_unlink.unlink() + # `write` on `stock.move.line` doesn't call `_recompute_state` (unlike to `unlink`), + # so it must be called for each move where no move line has been deleted. + (moves_to_unreserve - moves_not_to_recompute)._recompute_state() + return True + + def _generate_serial_numbers(self, next_serial_count=False): + """ This method will generate `lot_name` from a string (field + `next_serial`) and create a move line for each generated `lot_name`. + """ + self.ensure_one() + + if not next_serial_count: + next_serial_count = self.next_serial_count + # We look if the serial number contains at least one digit. + caught_initial_number = regex_findall("\d+", self.next_serial) + if not caught_initial_number: + raise UserError(_('The serial number must contain at least one digit.')) + # We base the serie on the last number find in the base serial number. + initial_number = caught_initial_number[-1] + padding = len(initial_number) + # We split the serial number to get the prefix and suffix. + splitted = regex_split(initial_number, self.next_serial) + # initial_number could appear several times in the SN, e.g. BAV023B00001S00001 + prefix = initial_number.join(splitted[:-1]) + suffix = splitted[-1] + initial_number = int(initial_number) + + lot_names = [] + for i in range(0, next_serial_count): + lot_names.append('%s%s%s' % ( + prefix, + str(initial_number + i).zfill(padding), + suffix + )) + move_lines_commands = self._generate_serial_move_line_commands(lot_names) + self.write({'move_line_ids': move_lines_commands}) + return True + + def _push_apply(self): + for move in self: + # if the move is already chained, there is no need to check push rules + if move.move_dest_ids: + continue + # 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 + # to receive goods without triggering the push rules again (which would duplicate chained operations) + domain = [('location_src_id', '=', move.location_dest_id.id), ('action', 'in', ('push', 'pull_push'))] + # first priority goes to the preferred routes defined on the move itself (e.g. coming from a SO line) + warehouse_id = move.warehouse_id or move.picking_id.picking_type_id.warehouse_id + if move.location_dest_id.company_id == self.env.company: + rules = self.env['procurement.group']._search_rule(move.route_ids, move.product_id, warehouse_id, domain) + else: + rules = self.sudo().env['procurement.group']._search_rule(move.route_ids, move.product_id, warehouse_id, domain) + # Make sure it is not returning the return + if rules and (not move.origin_returned_move_id or move.origin_returned_move_id.location_dest_id.id != rules.location_id.id): + rules._run_push(move) + + def _merge_moves_fields(self): + """ This method will return a dict of stock move’s values that represent the values of all moves in `self` merged. """ + state = self._get_relevant_state_among_moves() + origin = '/'.join(set(self.filtered(lambda m: m.origin).mapped('origin'))) + return { + 'product_uom_qty': sum(self.mapped('product_uom_qty')), + 'date': min(self.mapped('date')) if self.mapped('picking_id').move_type == 'direct' else max(self.mapped('date')), + 'move_dest_ids': [(4, m.id) for m in self.mapped('move_dest_ids')], + 'move_orig_ids': [(4, m.id) for m in self.mapped('move_orig_ids')], + 'state': state, + 'origin': origin, + } + + @api.model + def _prepare_merge_moves_distinct_fields(self): + return [ + 'product_id', 'price_unit', 'procure_method', 'location_id', 'location_dest_id', + 'product_uom', 'restrict_partner_id', 'scrapped', 'origin_returned_move_id', + 'package_level_id', 'propagate_cancel', 'description_picking', 'date_deadline' + ] + + @api.model + def _prepare_merge_move_sort_method(self, move): + move.ensure_one() + + description_picking = move.description_picking or "" + + return [ + move.product_id.id, move.price_unit, move.procure_method, move.location_id, move.location_dest_id, + move.product_uom.id, move.restrict_partner_id.id, move.scrapped, move.origin_returned_move_id.id, + move.package_level_id.id, move.propagate_cancel, description_picking + ] + + def _clean_merged(self): + """Cleanup hook used when merging moves""" + self.write({'propagate_cancel': False}) + + def _merge_moves(self, merge_into=False): + """ This method will, for each move in `self`, go up in their linked picking and try to + find in their existing moves a candidate into which we can merge the move. + :return: Recordset of moves passed to this method. If some of the passed moves were merged + into another existing one, return this one and not the (now unlinked) original. + """ + distinct_fields = self._prepare_merge_moves_distinct_fields() + + candidate_moves_list = [] + if not merge_into: + for picking in self.mapped('picking_id'): + candidate_moves_list.append(picking.move_lines) + else: + candidate_moves_list.append(merge_into | self) + + # Move removed after merge + moves_to_unlink = self.env['stock.move'] + moves_to_merge = [] + for candidate_moves in candidate_moves_list: + # First step find move to merge. + candidate_moves = candidate_moves.with_context(prefetch_fields=False) + for k, g in groupby(sorted(candidate_moves, key=self._prepare_merge_move_sort_method), key=itemgetter(*distinct_fields)): + moves = self.env['stock.move'].concat(*g).filtered(lambda m: m.state not in ('done', 'cancel', 'draft')) + # If we have multiple records we will merge then in a single one. + if len(moves) > 1: + moves_to_merge.append(moves) + + # second step merge its move lines, initial demand, ... + for moves in moves_to_merge: + # link all move lines to record 0 (the one we will keep). + moves.mapped('move_line_ids').write({'move_id': moves[0].id}) + # merge move data + moves[0].write(moves._merge_moves_fields()) + # update merged moves dicts + moves_to_unlink |= moves[1:] + + if moves_to_unlink: + # We are using propagate to False in order to not cancel destination moves merged in moves[0] + moves_to_unlink._clean_merged() + moves_to_unlink._action_cancel() + moves_to_unlink.sudo().unlink() + return (self | self.env['stock.move'].concat(*moves_to_merge)) - moves_to_unlink + + def _get_relevant_state_among_moves(self): + # We sort our moves by importance of state: + # ------------- 0 + # | Assigned | + # ------------- + # | Waiting | + # ------------- + # | Partial | + # ------------- + # | Confirm | + # ------------- len-1 + sort_map = { + 'assigned': 4, + 'waiting': 3, + 'partially_available': 2, + 'confirmed': 1, + } + moves_todo = self\ + .filtered(lambda move: move.state not in ['cancel', 'done'])\ + .sorted(key=lambda move: (sort_map.get(move.state, 0), move.product_uom_qty)) + # The picking should be the same for all moves. + if moves_todo[:1].picking_id and moves_todo[:1].picking_id.move_type == 'one': + most_important_move = moves_todo[0] + if most_important_move.state == 'confirmed': + return 'confirmed' if most_important_move.product_uom_qty else 'assigned' + elif most_important_move.state == 'partially_available': + return 'confirmed' + else: + return moves_todo[:1].state or 'draft' + elif moves_todo[:1].state != 'assigned' and any(move.state in ['assigned', 'partially_available'] for move in moves_todo): + return 'partially_available' + else: + least_important_move = moves_todo[-1:] + if least_important_move.state == 'confirmed' and least_important_move.product_uom_qty == 0: + return 'assigned' + else: + return moves_todo[-1:].state or 'draft' + + @api.onchange('product_id') + def onchange_product_id(self): + product = self.product_id.with_context(lang=self._get_lang()) + self.name = product.partner_ref + self.product_uom = product.uom_id.id + + @api.onchange('lot_ids') + def _onchange_lot_ids(self): + 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)) + quantity_done += self.product_id.uom_id._compute_quantity(len(self.lot_ids), self.product_uom) + self.update({'quantity_done': quantity_done}) + used_lots = self.env['stock.move.line'].search([ + ('company_id', '=', self.company_id.id), + ('product_id', '=', self.product_id.id), + ('lot_id', 'in', self.lot_ids.ids), + ('move_id', '!=', self._origin.id), + ('state', '!=', 'cancel') + ]) + if used_lots: + return { + 'warning': {'title': _('Warning'), 'message': _('Existing Serial numbers (%s). Please correct the serial numbers encoded.') % ','.join(used_lots.lot_id.mapped('display_name'))} + } + + @api.onchange('move_line_ids', 'move_line_nosuggest_ids') + def onchange_move_line_ids(self): + if not self.picking_type_id.use_create_lots: + # This onchange manages the creation of multiple lot name. We don't + # need that if the picking type disallows the creation of new lots. + return + + breaking_char = '\n' + if self.picking_type_id.show_reserved: + move_lines = self.move_line_ids + else: + move_lines = self.move_line_nosuggest_ids + + for move_line in move_lines: + # Look if the `lot_name` contains multiple values. + if breaking_char in (move_line.lot_name or ''): + split_lines = move_line.lot_name.split(breaking_char) + split_lines = list(filter(None, split_lines)) + move_line.lot_name = split_lines[0] + move_lines_commands = self._generate_serial_move_line_commands( + split_lines[1:], + origin_move_line=move_line, + ) + if self.picking_type_id.show_reserved: + self.update({'move_line_ids': move_lines_commands}) + else: + self.update({'move_line_nosuggest_ids': move_lines_commands}) + existing_lots = self.env['stock.production.lot'].search([ + ('company_id', '=', self.company_id.id), + ('product_id', '=', self.product_id.id), + ('name', 'in', split_lines), + ]) + if existing_lots: + return { + 'warning': {'title': _('Warning'), 'message': _('Existing Serial Numbers (%s). Please correct the serial numbers encoded.') % ','.join(existing_lots.mapped('display_name'))} + } + break + + @api.onchange('product_uom') + def onchange_product_uom(self): + if self.product_uom.factor > self.product_id.uom_id.factor: + return { + 'warning': { + 'title': "Unsafe unit of measure", + 'message': _("You are using a unit of measure smaller than the one you are using in " + "order to stock your product. This can lead to rounding problem on reserved quantity. " + "You should use the smaller unit of measure possible in order to valuate your stock or " + "change its rounding precision to a smaller value (example: 0.00001)."), + } + } + + def _key_assign_picking(self): + self.ensure_one() + return self.group_id, self.location_id, self.location_dest_id, self.picking_type_id + + def _search_picking_for_assignation(self): + self.ensure_one() + picking = self.env['stock.picking'].search([ + ('group_id', '=', self.group_id.id), + ('location_id', '=', self.location_id.id), + ('location_dest_id', '=', self.location_dest_id.id), + ('picking_type_id', '=', self.picking_type_id.id), + ('printed', '=', False), + ('immediate_transfer', '=', False), + ('state', 'in', ['draft', 'confirmed', 'waiting', 'partially_available', 'assigned'])], limit=1) + return picking + + def _assign_picking(self): + """ Try to assign the moves to an existing picking that has not been + reserved yet and has the same procurement group, locations and picking + type (moves should already have them identical). Otherwise, create a new + picking to assign them to. """ + Picking = self.env['stock.picking'] + grouped_moves = groupby(sorted(self, key=lambda m: [f.id for f in m._key_assign_picking()]), key=lambda m: [m._key_assign_picking()]) + for group, moves in grouped_moves: + moves = self.env['stock.move'].concat(*list(moves)) + new_picking = False + # Could pass the arguments contained in group but they are the same + # for each move that why moves[0] is acceptable + picking = moves[0]._search_picking_for_assignation() + if picking: + if any(picking.partner_id.id != m.partner_id.id or + picking.origin != m.origin for m in moves): + # If a picking is found, we'll append `move` to its move list and thus its + # `partner_id` and `ref` field will refer to multiple records. In this + # case, we chose to wipe them. + picking.write({ + 'partner_id': False, + 'origin': False, + }) + else: + new_picking = True + picking = Picking.create(moves._get_new_picking_values()) + + moves.write({'picking_id': picking.id}) + moves._assign_picking_post_process(new=new_picking) + return True + + def _assign_picking_post_process(self, new=False): + pass + + def _generate_serial_move_line_commands(self, lot_names, origin_move_line=None): + """Return a list of commands to update the move lines (write on + existing ones or create new ones). + Called when user want to create and assign multiple serial numbers in + one time (using the button/wizard or copy-paste a list in the field). + + :param lot_names: A list containing all serial number to assign. + :type lot_names: list + :param origin_move_line: A move line to duplicate the value from, default to None + :type origin_move_line: record of :class:`stock.move.line` + :return: A list of commands to create/update :class:`stock.move.line` + :rtype: list + """ + self.ensure_one() + + # Select the right move lines depending of the picking type configuration. + move_lines = self.env['stock.move.line'] + if self.picking_type_id.show_reserved: + move_lines = self.move_line_ids.filtered(lambda ml: not ml.lot_id and not ml.lot_name) + else: + move_lines = self.move_line_nosuggest_ids.filtered(lambda ml: not ml.lot_id and not ml.lot_name) + + if origin_move_line: + location_dest = origin_move_line.location_dest_id + else: + location_dest = self.location_dest_id._get_putaway_strategy(self.product_id) + move_line_vals = { + 'picking_id': self.picking_id.id, + 'location_dest_id': location_dest.id or self.location_dest_id.id, + 'location_id': self.location_id.id, + 'product_id': self.product_id.id, + 'product_uom_id': self.product_id.uom_id.id, + 'qty_done': 1, + } + if origin_move_line: + # `owner_id` and `package_id` are taken only in the case we create + # new move lines from an existing move line. Also, updates the + # `qty_done` because it could be usefull for products tracked by lot. + move_line_vals.update({ + 'owner_id': origin_move_line.owner_id.id, + 'package_id': origin_move_line.package_id.id, + 'qty_done': origin_move_line.qty_done or 1, + }) + + move_lines_commands = [] + for lot_name in lot_names: + # We write the lot name on an existing move line (if we have still one)... + if move_lines: + move_lines_commands.append((1, move_lines[0].id, { + 'lot_name': lot_name, + 'qty_done': 1, + })) + move_lines = move_lines[1:] + # ... or create a new move line with the serial name. + else: + move_line_cmd = dict(move_line_vals, lot_name=lot_name) + move_lines_commands.append((0, 0, move_line_cmd)) + return move_lines_commands + + def _get_new_picking_values(self): + """ return create values for new picking that will be linked with group + of moves in self. + """ + origins = self.filtered(lambda m: m.origin).mapped('origin') + origins = list(dict.fromkeys(origins)) # create a list of unique items + # Will display source document if any, when multiple different origins + # are found display a maximum of 5 + if len(origins) == 0: + origin = False + else: + origin = ','.join(origins[:5]) + if len(origins) > 5: + origin += "..." + partners = self.mapped('partner_id') + partner = len(partners) == 1 and partners.id or False + return { + 'origin': origin, + 'company_id': self.mapped('company_id').id, + 'user_id': False, + 'move_type': self.mapped('group_id').move_type or 'direct', + 'partner_id': partner, + 'picking_type_id': self.mapped('picking_type_id').id, + 'location_id': self.mapped('location_id').id, + 'location_dest_id': self.mapped('location_dest_id').id, + } + + def _should_be_assigned(self): + self.ensure_one() + return bool(not self.picking_id and self.picking_type_id) + + def _action_confirm(self, merge=True, merge_into=False): + """ Confirms stock move or put it in waiting if it's linked to another move. + :param: merge: According to this boolean, a newly confirmed move will be merged + in another move of the same picking sharing its characteristics. + """ + move_create_proc = self.env['stock.move'] + move_to_confirm = self.env['stock.move'] + move_waiting = self.env['stock.move'] + + to_assign = {} + for move in self: + if move.state != 'draft': + continue + # 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) + if move.move_orig_ids: + move_waiting |= move + else: + if move.procure_method == 'make_to_order': + move_create_proc |= move + else: + move_to_confirm |= move + if move._should_be_assigned(): + key = (move.group_id.id, move.location_id.id, move.location_dest_id.id) + if key not in to_assign: + to_assign[key] = self.env['stock.move'] + to_assign[key] |= move + + # create procurements for make to order moves + procurement_requests = [] + for move in move_create_proc: + values = move._prepare_procurement_values() + origin = (move.group_id and move.group_id.name or (move.origin or move.picking_id.name or "/")) + procurement_requests.append(self.env['procurement.group'].Procurement( + move.product_id, move.product_uom_qty, move.product_uom, + move.location_id, move.rule_id and move.rule_id.name or "/", + origin, move.company_id, values)) + self.env['procurement.group'].run(procurement_requests, raise_user_error=not self.env.context.get('from_orderpoint')) + + move_to_confirm.write({'state': 'confirmed'}) + (move_waiting | move_create_proc).write({'state': 'waiting'}) + + # assign picking in batch for all confirmed move that share the same details + for moves in to_assign.values(): + moves._assign_picking() + self._push_apply() + self._check_company() + moves = self + if merge: + moves = self._merge_moves(merge_into=merge_into) + # call `_action_assign` on every confirmed move which location_id bypasses the reservation + moves.filtered(lambda move: not move.picking_id.immediate_transfer and move._should_bypass_reservation() and move.state == 'confirmed')._action_assign() + return moves + + def _prepare_procurement_values(self): + """ Prepare specific key for moves or other componenets that will be created from a stock rule + comming from a stock move. This method could be override in order to add other custom key that could + be used in move/po creation. + """ + self.ensure_one() + group_id = self.group_id or False + if self.rule_id: + if self.rule_id.group_propagation_option == 'fixed' and self.rule_id.group_id: + group_id = self.rule_id.group_id + elif self.rule_id.group_propagation_option == 'none': + group_id = False + product_id = self.product_id.with_context(lang=self._get_lang()) + return { + 'product_description_variants': self.description_picking and self.description_picking.replace(product_id._get_description(self.picking_type_id), ''), + 'date_planned': self.date, + 'date_deadline': self.date_deadline, + 'move_dest_ids': self, + 'group_id': group_id, + 'route_ids': self.route_ids, + 'warehouse_id': self.warehouse_id or self.picking_id.picking_type_id.warehouse_id or self.picking_type_id.warehouse_id, + 'priority': self.priority, + 'orderpoint_id': self.orderpoint_id, + } + + def _prepare_move_line_vals(self, quantity=None, reserved_quant=None): + self.ensure_one() + # apply putaway + location_dest_id = self.location_dest_id._get_putaway_strategy(self.product_id).id or self.location_dest_id.id + vals = { + 'move_id': self.id, + 'product_id': self.product_id.id, + 'product_uom_id': self.product_uom.id, + 'location_id': self.location_id.id, + 'location_dest_id': location_dest_id, + 'picking_id': self.picking_id.id, + 'company_id': self.company_id.id, + } + if quantity: + rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure') + uom_quantity = self.product_id.uom_id._compute_quantity(quantity, self.product_uom, rounding_method='HALF-UP') + uom_quantity = float_round(uom_quantity, precision_digits=rounding) + uom_quantity_back_to_product_uom = self.product_uom._compute_quantity(uom_quantity, self.product_id.uom_id, rounding_method='HALF-UP') + if float_compare(quantity, uom_quantity_back_to_product_uom, precision_digits=rounding) == 0: + vals = dict(vals, product_uom_qty=uom_quantity) + else: + vals = dict(vals, product_uom_qty=quantity, product_uom_id=self.product_id.uom_id.id) + if reserved_quant: + vals = dict( + vals, + location_id=reserved_quant.location_id.id, + lot_id=reserved_quant.lot_id.id or False, + package_id=reserved_quant.package_id.id or False, + owner_id =reserved_quant.owner_id.id or False, + ) + return vals + + def _update_reserved_quantity(self, need, available_quantity, location_id, lot_id=None, package_id=None, owner_id=None, strict=True): + """ Create or update move lines. + """ + self.ensure_one() + + if not lot_id: + lot_id = self.env['stock.production.lot'] + if not package_id: + package_id = self.env['stock.quant.package'] + if not owner_id: + owner_id = self.env['res.partner'] + + taken_quantity = min(available_quantity, need) + + # `taken_quantity` is in the quants unit of measure. There's a possibility that the move's + # unit of measure won't be respected if we blindly reserve this quantity, a common usecase + # is if the move's unit of measure's rounding does not allow fractional reservation. We chose + # to convert `taken_quantity` to the move's unit of measure with a down rounding method and + # then get it back in the quants unit of measure with an half-up rounding_method. This + # way, we'll never reserve more than allowed. We do not apply this logic if + # `available_quantity` is brought by a chained move line. In this case, `_prepare_move_line_vals` + # will take care of changing the UOM to the UOM of the product. + if not strict and self.product_id.uom_id != self.product_uom: + taken_quantity_move_uom = self.product_id.uom_id._compute_quantity(taken_quantity, self.product_uom, rounding_method='DOWN') + taken_quantity = self.product_uom._compute_quantity(taken_quantity_move_uom, self.product_id.uom_id, rounding_method='HALF-UP') + + quants = [] + rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure') + + if self.product_id.tracking == 'serial': + if float_compare(taken_quantity, int(taken_quantity), precision_digits=rounding) != 0: + taken_quantity = 0 + + try: + with self.env.cr.savepoint(): + if not float_is_zero(taken_quantity, precision_rounding=self.product_id.uom_id.rounding): + quants = self.env['stock.quant']._update_reserved_quantity( + self.product_id, location_id, taken_quantity, lot_id=lot_id, + package_id=package_id, owner_id=owner_id, strict=strict + ) + except UserError: + taken_quantity = 0 + + # Find a candidate move line to update or create a new one. + for reserved_quant, quantity in quants: + to_update = self.move_line_ids.filtered(lambda ml: ml._reservation_is_updatable(quantity, reserved_quant)) + if to_update: + uom_quantity = self.product_id.uom_id._compute_quantity(quantity, to_update[0].product_uom_id, rounding_method='HALF-UP') + uom_quantity = float_round(uom_quantity, precision_digits=rounding) + 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') + if to_update and float_compare(quantity, uom_quantity_back_to_product_uom, precision_digits=rounding) == 0: + to_update[0].with_context(bypass_reservation_update=True).product_uom_qty += uom_quantity + else: + if self.product_id.tracking == 'serial': + for i in range(0, int(quantity)): + self.env['stock.move.line'].create(self._prepare_move_line_vals(quantity=1, reserved_quant=reserved_quant)) + else: + self.env['stock.move.line'].create(self._prepare_move_line_vals(quantity=quantity, reserved_quant=reserved_quant)) + return taken_quantity + + def _should_bypass_reservation(self): + self.ensure_one() + return self.location_id.should_bypass_reservation() or self.product_id.type != 'product' + + # necessary hook to be able to override move reservation to a restrict lot, owner, pack, location... + def _get_available_quantity(self, location_id, lot_id=None, package_id=None, owner_id=None, strict=False, allow_negative=False): + self.ensure_one() + 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) + + def _action_assign(self): + """ Reserve stock moves by creating their stock move lines. A stock move is + considered reserved once the sum of `product_qty` for all its move lines is + equal to its `product_qty`. If it is less, the stock move is considered + partially available. + """ + StockMove = self.env['stock.move'] + assigned_moves_ids = OrderedSet() + partially_available_moves_ids = OrderedSet() + # Read the `reserved_availability` field of the moves out of the loop to prevent unwanted + # cache invalidation when actually reserving the move. + reserved_availability = {move: move.reserved_availability for move in self} + roundings = {move: move.product_id.uom_id.rounding for move in self} + move_line_vals_list = [] + for move in self.filtered(lambda m: m.state in ['confirmed', 'waiting', 'partially_available']): + rounding = roundings[move] + missing_reserved_uom_quantity = move.product_uom_qty - reserved_availability[move] + missing_reserved_quantity = move.product_uom._compute_quantity(missing_reserved_uom_quantity, move.product_id.uom_id, rounding_method='HALF-UP') + if move._should_bypass_reservation(): + # create the move line(s) but do not impact quants + if move.product_id.tracking == 'serial' and (move.picking_type_id.use_create_lots or move.picking_type_id.use_existing_lots): + for i in range(0, int(missing_reserved_quantity)): + move_line_vals_list.append(move._prepare_move_line_vals(quantity=1)) + else: + to_update = move.move_line_ids.filtered(lambda ml: ml.product_uom_id == move.product_uom and + ml.location_id == move.location_id and + ml.location_dest_id == move.location_dest_id and + ml.picking_id == move.picking_id and + not ml.lot_id and + not ml.package_id and + not ml.owner_id) + if to_update: + to_update[0].product_uom_qty += missing_reserved_uom_quantity + else: + move_line_vals_list.append(move._prepare_move_line_vals(quantity=missing_reserved_quantity)) + assigned_moves_ids.add(move.id) + else: + if float_is_zero(move.product_uom_qty, precision_rounding=move.product_uom.rounding): + assigned_moves_ids.add(move.id) + elif not move.move_orig_ids: + if move.procure_method == 'make_to_order': + continue + # If we don't need any quantity, consider the move assigned. + need = missing_reserved_quantity + if float_is_zero(need, precision_rounding=rounding): + assigned_moves_ids.add(move.id) + continue + # Reserve new quants and create move lines accordingly. + forced_package_id = move.package_level_id.package_id or None + available_quantity = move._get_available_quantity(move.location_id, package_id=forced_package_id) + if available_quantity <= 0: + continue + taken_quantity = move._update_reserved_quantity(need, available_quantity, move.location_id, package_id=forced_package_id, strict=False) + if float_is_zero(taken_quantity, precision_rounding=rounding): + continue + if float_compare(need, taken_quantity, precision_rounding=rounding) == 0: + assigned_moves_ids.add(move.id) + else: + partially_available_moves_ids.add(move.id) + else: + # Check what our parents brought and what our siblings took in order to + # determine what we can distribute. + # `qty_done` is in `ml.product_uom_id` and, as we will later increase + # the reserved quantity on the quants, convert it here in + # `product_id.uom_id` (the UOM of the quants is the UOM of the product). + move_lines_in = move.move_orig_ids.filtered(lambda m: m.state == 'done').mapped('move_line_ids') + keys_in_groupby = ['location_dest_id', 'lot_id', 'result_package_id', 'owner_id'] + + def _keys_in_sorted(ml): + return (ml.location_dest_id.id, ml.lot_id.id, ml.result_package_id.id, ml.owner_id.id) + + grouped_move_lines_in = {} + for k, g in groupby(sorted(move_lines_in, key=_keys_in_sorted), key=itemgetter(*keys_in_groupby)): + qty_done = 0 + for ml in g: + qty_done += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id) + grouped_move_lines_in[k] = qty_done + move_lines_out_done = (move.move_orig_ids.mapped('move_dest_ids') - move)\ + .filtered(lambda m: m.state in ['done'])\ + .mapped('move_line_ids') + # As we defer the write on the stock.move's state at the end of the loop, there + # could be moves to consider in what our siblings already took. + moves_out_siblings = move.move_orig_ids.mapped('move_dest_ids') - move + moves_out_siblings_to_consider = moves_out_siblings & (StockMove.browse(assigned_moves_ids) + StockMove.browse(partially_available_moves_ids)) + reserved_moves_out_siblings = moves_out_siblings.filtered(lambda m: m.state in ['partially_available', 'assigned']) + move_lines_out_reserved = (reserved_moves_out_siblings | moves_out_siblings_to_consider).mapped('move_line_ids') + keys_out_groupby = ['location_id', 'lot_id', 'package_id', 'owner_id'] + + def _keys_out_sorted(ml): + return (ml.location_id.id, ml.lot_id.id, ml.package_id.id, ml.owner_id.id) + + grouped_move_lines_out = {} + for k, g in groupby(sorted(move_lines_out_done, key=_keys_out_sorted), key=itemgetter(*keys_out_groupby)): + qty_done = 0 + for ml in g: + qty_done += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id) + grouped_move_lines_out[k] = qty_done + for k, g in groupby(sorted(move_lines_out_reserved, key=_keys_out_sorted), key=itemgetter(*keys_out_groupby)): + grouped_move_lines_out[k] = sum(self.env['stock.move.line'].concat(*list(g)).mapped('product_qty')) + available_move_lines = {key: grouped_move_lines_in[key] - grouped_move_lines_out.get(key, 0) for key in grouped_move_lines_in.keys()} + # pop key if the quantity available amount to 0 + available_move_lines = dict((k, v) for k, v in available_move_lines.items() if v) + + if not available_move_lines: + continue + for move_line in move.move_line_ids.filtered(lambda m: m.product_qty): + if available_move_lines.get((move_line.location_id, move_line.lot_id, move_line.result_package_id, move_line.owner_id)): + available_move_lines[(move_line.location_id, move_line.lot_id, move_line.result_package_id, move_line.owner_id)] -= move_line.product_qty + for (location_id, lot_id, package_id, owner_id), quantity in available_move_lines.items(): + need = move.product_qty - sum(move.move_line_ids.mapped('product_qty')) + # `quantity` is what is brought by chained done move lines. We double check + # here this quantity is available on the quants themselves. If not, this + # could be the result of an inventory adjustment that removed totally of + # partially `quantity`. When this happens, we chose to reserve the maximum + # still available. This situation could not happen on MTS move, because in + # this case `quantity` is directly the quantity on the quants themselves. + available_quantity = move._get_available_quantity(location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=True) + if float_is_zero(available_quantity, precision_rounding=rounding): + continue + taken_quantity = move._update_reserved_quantity(need, min(quantity, available_quantity), location_id, lot_id, package_id, owner_id) + if float_is_zero(taken_quantity, precision_rounding=rounding): + continue + if float_is_zero(need - taken_quantity, precision_rounding=rounding): + assigned_moves_ids.add(move.id) + break + partially_available_moves_ids.add(move.id) + if move.product_id.tracking == 'serial': + move.next_serial_count = move.product_uom_qty + + self.env['stock.move.line'].create(move_line_vals_list) + StockMove.browse(partially_available_moves_ids).write({'state': 'partially_available'}) + StockMove.browse(assigned_moves_ids).write({'state': 'assigned'}) + self.mapped('picking_id')._check_entire_pack() + + def _action_cancel(self): + if any(move.state == 'done' and not move.scrapped for move in self): + 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.')) + moves_to_cancel = self.filtered(lambda m: m.state != 'cancel') + # self cannot contain moves that are either cancelled or done, therefore we can safely + # unlink all associated move_line_ids + moves_to_cancel._do_unreserve() + + for move in moves_to_cancel: + siblings_states = (move.move_dest_ids.mapped('move_orig_ids') - move).mapped('state') + if move.propagate_cancel: + # only cancel the next move if all my siblings are also cancelled + if all(state == 'cancel' for state in siblings_states): + move.move_dest_ids.filtered(lambda m: m.state != 'done')._action_cancel() + else: + if all(state in ('done', 'cancel') for state in siblings_states): + move.move_dest_ids.write({'procure_method': 'make_to_stock'}) + move.move_dest_ids.write({'move_orig_ids': [(3, move.id, 0)]}) + self.write({ + 'state': 'cancel', + 'move_orig_ids': [(5, 0, 0)], + 'procure_method': 'make_to_stock', + }) + return True + + def _prepare_extra_move_vals(self, qty): + vals = { + 'procure_method': 'make_to_stock', + 'origin_returned_move_id': self.origin_returned_move_id.id, + 'product_uom_qty': qty, + 'picking_id': self.picking_id.id, + 'price_unit': self.price_unit, + } + return vals + + def _create_extra_move(self): + """ If the quantity done on a move exceeds its quantity todo, this method will create an + extra move attached to a (potentially split) move line. If the previous condition is not + met, it'll return an empty recordset. + + The rationale for the creation of an extra move is the application of a potential push + rule that will handle the extra quantities. + """ + extra_move = self + rounding = self.product_uom.rounding + # moves created after the picking is assigned do not have `product_uom_qty`, but we shouldn't create extra moves for them + if float_compare(self.quantity_done, self.product_uom_qty, precision_rounding=rounding) > 0: + # create the extra moves + extra_move_quantity = float_round( + self.quantity_done - self.product_uom_qty, + precision_rounding=rounding, + rounding_method='HALF-UP') + extra_move_vals = self._prepare_extra_move_vals(extra_move_quantity) + extra_move = self.copy(default=extra_move_vals) + + merge_into_self = all(self[field] == extra_move[field] for field in self._prepare_merge_moves_distinct_fields()) + + if merge_into_self and extra_move.picking_id: + extra_move = extra_move._action_confirm(merge_into=self) + return extra_move + else: + extra_move = extra_move._action_confirm() + + # link it to some move lines. We don't need to do it for move since they should be merged. + if not merge_into_self or not extra_move.picking_id: + for move_line in self.move_line_ids.filtered(lambda ml: ml.qty_done): + if float_compare(move_line.qty_done, extra_move_quantity, precision_rounding=rounding) <= 0: + # move this move line to our extra move + move_line.move_id = extra_move.id + extra_move_quantity -= move_line.qty_done + else: + # split this move line and assign the new part to our extra move + quantity_split = float_round( + move_line.qty_done - extra_move_quantity, + precision_rounding=self.product_uom.rounding, + rounding_method='UP') + move_line.qty_done = quantity_split + move_line.copy(default={'move_id': extra_move.id, 'qty_done': extra_move_quantity, 'product_uom_qty': 0}) + extra_move_quantity -= extra_move_quantity + if extra_move_quantity == 0.0: + break + return extra_move | self + + def _action_done(self, cancel_backorder=False): + self.filtered(lambda move: move.state == 'draft')._action_confirm() # MRP allows scrapping draft moves + moves = self.exists().filtered(lambda x: x.state not in ('done', 'cancel')) + moves_todo = self.env['stock.move'] + + # Cancel moves where necessary ; we should do it before creating the extra moves because + # this operation could trigger a merge of moves. + for move in moves: + if move.quantity_done <= 0: + if float_compare(move.product_uom_qty, 0.0, precision_rounding=move.product_uom.rounding) == 0 or cancel_backorder: + move._action_cancel() + + # Create extra moves where necessary + for move in moves: + if move.state == 'cancel' or move.quantity_done <= 0: + continue + + moves_todo |= move._create_extra_move() + + moves_todo._check_company() + # Split moves where necessary and move quants + backorder_moves_vals = [] + for move in moves_todo: + # To know whether we need to create a backorder or not, round to the general product's + # decimal precision and not the product's UOM. + rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure') + if float_compare(move.quantity_done, move.product_uom_qty, precision_digits=rounding) < 0: + # Need to do some kind of conversion here + qty_split = move.product_uom._compute_quantity(move.product_uom_qty - move.quantity_done, move.product_id.uom_id, rounding_method='HALF-UP') + new_move_vals = move._split(qty_split) + backorder_moves_vals += new_move_vals + backorder_moves = self.env['stock.move'].create(backorder_moves_vals) + backorder_moves._action_confirm(merge=False) + if cancel_backorder: + backorder_moves.with_context(moves_todo=moves_todo)._action_cancel() + moves_todo.mapped('move_line_ids').sorted()._action_done() + # Check the consistency of the result packages; there should be an unique location across + # the contained quants. + for result_package in moves_todo\ + .mapped('move_line_ids.result_package_id')\ + .filtered(lambda p: p.quant_ids and len(p.quant_ids) > 1): + 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: + raise UserError(_('You cannot move the same package content more than once in the same transfer or split the same package into two location.')) + picking = moves_todo.mapped('picking_id') + moves_todo.write({'state': 'done', 'date': fields.Datetime.now()}) + + move_dests_per_company = defaultdict(lambda: self.env['stock.move']) + for move_dest in moves_todo.move_dest_ids: + move_dests_per_company[move_dest.company_id.id] |= move_dest + for company_id, move_dests in move_dests_per_company.items(): + move_dests.sudo().with_company(company_id)._action_assign() + + # We don't want to create back order for scrap moves + # Replace by a kwarg in master + if self.env.context.get('is_scrap'): + return moves_todo + + if picking and not cancel_backorder: + picking._create_backorder() + return moves_todo + + def unlink(self): + if any(move.state not in ('draft', 'cancel') for move in self): + raise UserError(_('You can only delete draft moves.')) + # With the non plannified picking, draft moves could have some move lines. + self.with_context(prefetch_fields=False).mapped('move_line_ids').unlink() + return super(StockMove, self).unlink() + + def _prepare_move_split_vals(self, qty): + vals = { + 'product_uom_qty': qty, + 'procure_method': 'make_to_stock', + 'move_dest_ids': [(4, x.id) for x in self.move_dest_ids if x.state not in ('done', 'cancel')], + 'move_orig_ids': [(4, x.id) for x in self.move_orig_ids], + 'origin_returned_move_id': self.origin_returned_move_id.id, + 'price_unit': self.price_unit, + } + if self.env.context.get('force_split_uom_id'): + vals['product_uom'] = self.env.context['force_split_uom_id'] + return vals + + def _split(self, qty, restrict_partner_id=False): + """ Splits `self` quantity and return values for a new moves to be created afterwards + + :param qty: float. quantity to split (given in product UoM) + :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. + :returns: list of dict. stock move values """ + self.ensure_one() + if self.state in ('done', 'cancel'): + raise UserError(_('You cannot split a stock move that has been set to \'Done\'.')) + elif self.state == 'draft': + # we restrict the split of a draft move because if not confirmed yet, it may be replaced by several other moves in + # case of phantom bom (with mrp module). And we don't want to deal with this complexity by copying the product that will explode. + raise UserError(_('You cannot split a draft move. It needs to be confirmed first.')) + if float_is_zero(qty, precision_rounding=self.product_id.uom_id.rounding) or self.product_qty <= qty: + return [] + + decimal_precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + + # `qty` passed as argument is the quantity to backorder and is always expressed in the + # quants UOM. If we're able to convert back and forth this quantity in the move's and the + # quants UOM, the backordered move can keep the UOM of the move. Else, we'll create is in + # the UOM of the quants. + uom_qty = self.product_id.uom_id._compute_quantity(qty, self.product_uom, rounding_method='HALF-UP') + 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: + defaults = self._prepare_move_split_vals(uom_qty) + else: + defaults = self.with_context(force_split_uom_id=self.product_id.uom_id.id)._prepare_move_split_vals(qty) + + if restrict_partner_id: + defaults['restrict_partner_id'] = restrict_partner_id + + # TDE CLEANME: remove context key + add as parameter + if self.env.context.get('source_location_id'): + defaults['location_id'] = self.env.context['source_location_id'] + new_move_vals = self.with_context(rounding_method='HALF-UP').copy_data(defaults) + + # Update the original `product_qty` of the move. Use the general product's decimal + # precision and not the move's UOM to handle case where the `quantity_done` is not + # compatible with the move's UOM. + new_product_qty = self.product_id.uom_id._compute_quantity(self.product_qty - qty, self.product_uom, round=False) + new_product_qty = float_round(new_product_qty, precision_digits=self.env['decimal.precision'].precision_get('Product Unit of Measure')) + self.with_context(do_not_unreserve=True, rounding_method='HALF-UP').write({'product_uom_qty': new_product_qty}) + return new_move_vals + + def _recompute_state(self): + moves_state_to_write = defaultdict(set) + for move in self: + if move.state in ('cancel', 'done', 'draft'): + continue + elif move.reserved_availability == move.product_uom_qty: + moves_state_to_write['assigned'].add(move.id) + elif move.reserved_availability and move.reserved_availability <= move.product_uom_qty: + moves_state_to_write['partially_available'].add(move.id) + elif move.procure_method == 'make_to_order' and not move.move_orig_ids: + moves_state_to_write['waiting'].add(move.id) + elif move.move_orig_ids and any(orig.state not in ('done', 'cancel') for orig in move.move_orig_ids): + moves_state_to_write['waiting'].add(move.id) + else: + moves_state_to_write['confirmed'].add(move.id) + for state, moves_ids in moves_state_to_write.items(): + self.browse(moves_ids).write({'state': state}) + + @api.model + def _consuming_picking_types(self): + return ['outgoing'] + + def _get_lang(self): + """Determine language to use for translated description""" + return self.picking_id.partner_id.lang or self.partner_id.lang or self.env.user.lang + + def _get_source_document(self): + """ Return the move's document, used by `report.stock.report_product_product_replenishment` + and must be overrided to add more document type in the report. + """ + self.ensure_one() + return self.picking_id or False + + def _get_upstream_documents_and_responsibles(self, visited): + if self.move_orig_ids and any(m.state not in ('done', 'cancel') for m in self.move_orig_ids): + result = set() + visited |= self + for move in self.move_orig_ids: + if move.state not in ('done', 'cancel'): + for document, responsible, visited in move._get_upstream_documents_and_responsibles(visited): + result.add((document, responsible, visited)) + return result + else: + return [(self.picking_id, self.product_id.responsible_id, visited)] + + def _set_quantity_done_prepare_vals(self, qty): + res = [] + for ml in self.move_line_ids: + ml_qty = ml.product_uom_qty - ml.qty_done + if float_compare(ml_qty, 0, precision_rounding=ml.product_uom_id.rounding) <= 0: + continue + # Convert move line qty into move uom + if ml.product_uom_id != self.product_uom: + ml_qty = ml.product_uom_id._compute_quantity(ml_qty, self.product_uom, round=False) + + taken_qty = min(qty, ml_qty) + # Convert taken qty into move line uom + if ml.product_uom_id != self.product_uom: + taken_qty = self.product_uom._compute_quantity(ml_qty, ml.product_uom_id, round=False) + + # Assign qty_done and explicitly round to make sure there is no inconsistency between + # ml.qty_done and qty. + taken_qty = float_round(taken_qty, precision_rounding=ml.product_uom_id.rounding) + res.append((1, ml.id, {'qty_done': ml.qty_done + taken_qty})) + if ml.product_uom_id != self.product_uom: + taken_qty = ml.product_uom_id._compute_quantity(ml_qty, self.product_uom, round=False) + qty -= taken_qty + + if float_compare(qty, 0.0, precision_rounding=self.product_uom.rounding) <= 0: + break + + for ml in self.move_line_ids: + 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): + res.append((2, ml.id)) + + if float_compare(qty, 0.0, precision_rounding=self.product_uom.rounding) > 0: + if self.product_id.tracking != 'serial': + vals = self._prepare_move_line_vals(quantity=0) + vals['qty_done'] = qty + res.append((0, 0, vals)) + else: + uom_qty = self.product_uom._compute_quantity(qty, self.product_id.uom_id) + for i in range(0, int(uom_qty)): + vals = self._prepare_move_line_vals(quantity=0) + vals['qty_done'] = 1 + vals['product_uom_id'] = self.product_id.uom_id.id + res.append((0, 0, vals)) + return res + + def _set_quantity_done(self, qty): + """ + Set the given quantity as quantity done on the move through the move lines. The method is + able to handle move lines with a different UoM than the move (but honestly, this would be + looking for trouble...). + @param qty: quantity in the UoM of move.product_uom + """ + self.move_line_ids = self._set_quantity_done_prepare_vals(qty) + + def _adjust_procure_method(self): + """ This method will try to apply the procure method MTO on some moves if + a compatible MTO route is found. Else the procure method will be set to MTS + """ + # Prepare the MTSO variables. They are needed since MTSO moves are handled separately. + # We need 2 dicts: + # - needed quantity per location per product + # - forecasted quantity per location per product + mtso_products_by_locations = defaultdict(list) + mtso_needed_qties_by_loc = defaultdict(dict) + mtso_free_qties_by_loc = {} + mtso_moves = self.env['stock.move'] + + for move in self: + product_id = move.product_id + domain = [ + ('location_src_id', '=', move.location_id.id), + ('location_id', '=', move.location_dest_id.id), + ('action', '!=', 'push') + ] + rules = self.env['procurement.group']._search_rule(False, product_id, move.warehouse_id, domain) + if rules: + if rules.procure_method in ['make_to_order', 'make_to_stock']: + move.procure_method = rules.procure_method + else: + # Get the needed quantity for the `mts_else_mto` moves. + mtso_needed_qties_by_loc[rules.location_src_id].setdefault(product_id.id, 0) + mtso_needed_qties_by_loc[rules.location_src_id][product_id.id] += move.product_qty + + # This allow us to get the forecasted quantity in batch later on + mtso_products_by_locations[rules.location_src_id].append(product_id.id) + mtso_moves |= move + else: + move.procure_method = 'make_to_stock' + + # Get the forecasted quantity for the `mts_else_mto` moves. + for location, product_ids in mtso_products_by_locations.items(): + products = self.env['product.product'].browse(product_ids).with_context(location=location.id) + mtso_free_qties_by_loc[location] = {product.id: product.free_qty for product in products} + + # Now that we have the needed and forecasted quantity per location and per product, we can + # choose whether the mtso_moves need to be MTO or MTS. + for move in mtso_moves: + needed_qty = move.product_qty + forecasted_qty = mtso_free_qties_by_loc[move.location_id][move.product_id.id] + if float_compare(needed_qty, forecasted_qty, precision_rounding=product_id.uom_id.rounding) <= 0: + move.procure_method = 'make_to_stock' + mtso_free_qties_by_loc[move.location_id][move.product_id.id] -= needed_qty + else: + move.procure_method = 'make_to_order' + + def _show_details_in_draft(self): + self.ensure_one() + return self.state != 'draft' or (self.picking_id.immediate_transfer and self.state == 'draft') + + def _trigger_scheduler(self): + """ Check for auto-triggered orderpoints and trigger them. """ + if not self or self.env['ir.config_parameter'].sudo().get_param('stock.no_auto_scheduler'): + return + + orderpoints_by_company = defaultdict(lambda: self.env['stock.warehouse.orderpoint']) + for move in self: + orderpoint = self.env['stock.warehouse.orderpoint'].search([ + ('product_id', '=', move.product_id.id), + ('trigger', '=', 'auto'), + ('location_id', 'parent_of', move.location_id.id), + ('company_id', '=', move.company_id.id) + ], limit=1) + if orderpoint: + orderpoints_by_company[orderpoint.company_id] |= orderpoint + for company, orderpoints in orderpoints_by_company.items(): + orderpoints._procure_orderpoint_confirm(company_id=company, raise_user_error=False) + + def _trigger_assign(self): + """ Check for and trigger action_assign for confirmed/partially_available moves related to done moves. + Disable auto reservation if user configured to do so. + """ + if not self or self.env['ir.config_parameter'].sudo().get_param('stock.picking_no_auto_reserve'): + return + + domains = [] + for move in self: + domains.append([('product_id', '=', move.product_id.id), ('location_id', '=', move.location_dest_id.id)]) + static_domain = [('state', 'in', ['confirmed', 'partially_available']), ('procure_method', '=', 'make_to_stock')] + moves_to_reserve = self.env['stock.move'].search(expression.AND([static_domain, expression.OR(domains)])) + moves_to_reserve._action_assign() |
