summaryrefslogtreecommitdiff
path: root/addons/stock/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/stock/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/stock/models')
-rw-r--r--addons/stock/models/__init__.py21
-rw-r--r--addons/stock/models/barcode.py20
-rw-r--r--addons/stock/models/product.py918
-rw-r--r--addons/stock/models/product_strategy.py88
-rw-r--r--addons/stock/models/res_company.py188
-rw-r--r--addons/stock/models/res_config_settings.py98
-rw-r--r--addons/stock/models/res_partner.py21
-rw-r--r--addons/stock/models/stock_inventory.py591
-rw-r--r--addons/stock/models/stock_location.py214
-rw-r--r--addons/stock/models/stock_move.py1808
-rw-r--r--addons/stock/models/stock_move_line.py676
-rw-r--r--addons/stock/models/stock_orderpoint.py517
-rw-r--r--addons/stock/models/stock_package_level.py214
-rw-r--r--addons/stock/models/stock_picking.py1409
-rw-r--r--addons/stock/models/stock_production_lot.py108
-rw-r--r--addons/stock/models/stock_quant.py745
-rw-r--r--addons/stock/models/stock_rule.py560
-rw-r--r--addons/stock/models/stock_scrap.py181
-rw-r--r--addons/stock/models/stock_warehouse.py1048
19 files changed, 9425 insertions, 0 deletions
diff --git a/addons/stock/models/__init__.py b/addons/stock/models/__init__.py
new file mode 100644
index 00000000..115b4f42
--- /dev/null
+++ b/addons/stock/models/__init__.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import barcode
+from . import product_strategy
+from . import res_company
+from . import res_partner
+from . import res_config_settings
+from . import stock_inventory
+from . import stock_location
+from . import stock_move
+from . import stock_move_line
+from . import stock_orderpoint
+from . import stock_production_lot
+from . import stock_picking
+from . import stock_quant
+from . import stock_rule
+from . import stock_warehouse
+from . import stock_scrap
+from . import product
+from . import stock_package_level
diff --git a/addons/stock/models/barcode.py b/addons/stock/models/barcode.py
new file mode 100644
index 00000000..e81afcfe
--- /dev/null
+++ b/addons/stock/models/barcode.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class BarcodeRule(models.Model):
+ _inherit = 'barcode.rule'
+
+ type = fields.Selection(selection_add=[
+ ('weight', 'Weighted Product'),
+ ('location', 'Location'),
+ ('lot', 'Lot'),
+ ('package', 'Package')
+ ], ondelete={
+ 'weight': 'set default',
+ 'location': 'set default',
+ 'lot': 'set default',
+ 'package': 'set default',
+ })
diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py
new file mode 100644
index 00000000..d7b29653
--- /dev/null
+++ b/addons/stock/models/product.py
@@ -0,0 +1,918 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import operator as py_operator
+from ast import literal_eval
+from collections import defaultdict
+
+from odoo import _, api, fields, models, SUPERUSER_ID
+from odoo.exceptions import UserError
+from odoo.osv import expression
+from odoo.tools import pycompat,float_is_zero
+from odoo.tools.float_utils import float_round
+
+OPERATORS = {
+ '<': py_operator.lt,
+ '>': py_operator.gt,
+ '<=': py_operator.le,
+ '>=': py_operator.ge,
+ '=': py_operator.eq,
+ '!=': py_operator.ne
+}
+
+class Product(models.Model):
+ _inherit = "product.product"
+
+ stock_quant_ids = fields.One2many('stock.quant', 'product_id', help='Technical: used to compute quantities.')
+ stock_move_ids = fields.One2many('stock.move', 'product_id', help='Technical: used to compute quantities.')
+ qty_available = fields.Float(
+ 'Quantity On Hand', compute='_compute_quantities', search='_search_qty_available',
+ digits='Product Unit of Measure', compute_sudo=False,
+ help="Current quantity of products.\n"
+ "In a context with a single Stock Location, this includes "
+ "goods stored at this Location, or any of its children.\n"
+ "In a context with a single Warehouse, this includes "
+ "goods stored in the Stock Location of this Warehouse, or any "
+ "of its children.\n"
+ "stored in the Stock Location of the Warehouse of this Shop, "
+ "or any of its children.\n"
+ "Otherwise, this includes goods stored in any Stock Location "
+ "with 'internal' type.")
+ virtual_available = fields.Float(
+ 'Forecast Quantity', compute='_compute_quantities', search='_search_virtual_available',
+ digits='Product Unit of Measure', compute_sudo=False,
+ help="Forecast quantity (computed as Quantity On Hand "
+ "- Outgoing + Incoming)\n"
+ "In a context with a single Stock Location, this includes "
+ "goods stored in this location, or any of its children.\n"
+ "In a context with a single Warehouse, this includes "
+ "goods stored in the Stock Location of this Warehouse, or any "
+ "of its children.\n"
+ "Otherwise, this includes goods stored in any Stock Location "
+ "with 'internal' type.")
+ free_qty = fields.Float(
+ 'Free To Use Quantity ', compute='_compute_quantities', search='_search_free_qty',
+ digits='Product Unit of Measure', compute_sudo=False,
+ help="Forecast quantity (computed as Quantity On Hand "
+ "- reserved quantity)\n"
+ "In a context with a single Stock Location, this includes "
+ "goods stored in this location, or any of its children.\n"
+ "In a context with a single Warehouse, this includes "
+ "goods stored in the Stock Location of this Warehouse, or any "
+ "of its children.\n"
+ "Otherwise, this includes goods stored in any Stock Location "
+ "with 'internal' type.")
+ incoming_qty = fields.Float(
+ 'Incoming', compute='_compute_quantities', search='_search_incoming_qty',
+ digits='Product Unit of Measure', compute_sudo=False,
+ help="Quantity of planned incoming products.\n"
+ "In a context with a single Stock Location, this includes "
+ "goods arriving to this Location, or any of its children.\n"
+ "In a context with a single Warehouse, this includes "
+ "goods arriving to the Stock Location of this Warehouse, or "
+ "any of its children.\n"
+ "Otherwise, this includes goods arriving to any Stock "
+ "Location with 'internal' type.")
+ outgoing_qty = fields.Float(
+ 'Outgoing', compute='_compute_quantities', search='_search_outgoing_qty',
+ digits='Product Unit of Measure', compute_sudo=False,
+ help="Quantity of planned outgoing products.\n"
+ "In a context with a single Stock Location, this includes "
+ "goods leaving this Location, or any of its children.\n"
+ "In a context with a single Warehouse, this includes "
+ "goods leaving the Stock Location of this Warehouse, or "
+ "any of its children.\n"
+ "Otherwise, this includes goods leaving any Stock "
+ "Location with 'internal' type.")
+
+ orderpoint_ids = fields.One2many('stock.warehouse.orderpoint', 'product_id', 'Minimum Stock Rules')
+ nbr_reordering_rules = fields.Integer('Reordering Rules',
+ compute='_compute_nbr_reordering_rules', compute_sudo=False)
+ reordering_min_qty = fields.Float(
+ compute='_compute_nbr_reordering_rules', compute_sudo=False)
+ reordering_max_qty = fields.Float(
+ compute='_compute_nbr_reordering_rules', compute_sudo=False)
+ putaway_rule_ids = fields.One2many('stock.putaway.rule', 'product_id', 'Putaway Rules')
+
+ @api.depends('stock_move_ids.product_qty', 'stock_move_ids.state')
+ @api.depends_context(
+ 'lot_id', 'owner_id', 'package_id', 'from_date', 'to_date',
+ 'location', 'warehouse',
+ )
+ def _compute_quantities(self):
+ products = self.filtered(lambda p: p.type != 'service')
+ res = products._compute_quantities_dict(self._context.get('lot_id'), self._context.get('owner_id'), self._context.get('package_id'), self._context.get('from_date'), self._context.get('to_date'))
+ for product in products:
+ product.qty_available = res[product.id]['qty_available']
+ product.incoming_qty = res[product.id]['incoming_qty']
+ product.outgoing_qty = res[product.id]['outgoing_qty']
+ product.virtual_available = res[product.id]['virtual_available']
+ product.free_qty = res[product.id]['free_qty']
+ # Services need to be set with 0.0 for all quantities
+ services = self - products
+ services.qty_available = 0.0
+ services.incoming_qty = 0.0
+ services.outgoing_qty = 0.0
+ services.virtual_available = 0.0
+ services.free_qty = 0.0
+
+ def _product_available(self, field_names=None, arg=False):
+ """ Compatibility method """
+ return self._compute_quantities_dict(self._context.get('lot_id'), self._context.get('owner_id'), self._context.get('package_id'), self._context.get('from_date'), self._context.get('to_date'))
+
+ def _compute_quantities_dict(self, lot_id, owner_id, package_id, from_date=False, to_date=False):
+ domain_quant_loc, domain_move_in_loc, domain_move_out_loc = self._get_domain_locations()
+ domain_quant = [('product_id', 'in', self.ids)] + domain_quant_loc
+ dates_in_the_past = False
+ # only to_date as to_date will correspond to qty_available
+ to_date = fields.Datetime.to_datetime(to_date)
+ if to_date and to_date < fields.Datetime.now():
+ dates_in_the_past = True
+
+ domain_move_in = [('product_id', 'in', self.ids)] + domain_move_in_loc
+ domain_move_out = [('product_id', 'in', self.ids)] + domain_move_out_loc
+ if lot_id is not None:
+ domain_quant += [('lot_id', '=', lot_id)]
+ if owner_id is not None:
+ domain_quant += [('owner_id', '=', owner_id)]
+ domain_move_in += [('restrict_partner_id', '=', owner_id)]
+ domain_move_out += [('restrict_partner_id', '=', owner_id)]
+ if package_id is not None:
+ domain_quant += [('package_id', '=', package_id)]
+ if dates_in_the_past:
+ domain_move_in_done = list(domain_move_in)
+ domain_move_out_done = list(domain_move_out)
+ if from_date:
+ date_date_expected_domain_from = [('date', '>=', from_date)]
+ domain_move_in += date_date_expected_domain_from
+ domain_move_out += date_date_expected_domain_from
+ if to_date:
+ date_date_expected_domain_to = [('date', '<=', to_date)]
+ domain_move_in += date_date_expected_domain_to
+ domain_move_out += date_date_expected_domain_to
+
+ Move = self.env['stock.move'].with_context(active_test=False)
+ Quant = self.env['stock.quant'].with_context(active_test=False)
+ domain_move_in_todo = [('state', 'in', ('waiting', 'confirmed', 'assigned', 'partially_available'))] + domain_move_in
+ domain_move_out_todo = [('state', 'in', ('waiting', 'confirmed', 'assigned', 'partially_available'))] + domain_move_out
+ moves_in_res = dict((item['product_id'][0], item['product_qty']) for item in Move.read_group(domain_move_in_todo, ['product_id', 'product_qty'], ['product_id'], orderby='id'))
+ moves_out_res = dict((item['product_id'][0], item['product_qty']) for item in Move.read_group(domain_move_out_todo, ['product_id', 'product_qty'], ['product_id'], orderby='id'))
+ quants_res = dict((item['product_id'][0], (item['quantity'], item['reserved_quantity'])) for item in Quant.read_group(domain_quant, ['product_id', 'quantity', 'reserved_quantity'], ['product_id'], orderby='id'))
+ if dates_in_the_past:
+ # Calculate the moves that were done before now to calculate back in time (as most questions will be recent ones)
+ domain_move_in_done = [('state', '=', 'done'), ('date', '>', to_date)] + domain_move_in_done
+ domain_move_out_done = [('state', '=', 'done'), ('date', '>', to_date)] + domain_move_out_done
+ moves_in_res_past = dict((item['product_id'][0], item['product_qty']) for item in Move.read_group(domain_move_in_done, ['product_id', 'product_qty'], ['product_id'], orderby='id'))
+ moves_out_res_past = dict((item['product_id'][0], item['product_qty']) for item in Move.read_group(domain_move_out_done, ['product_id', 'product_qty'], ['product_id'], orderby='id'))
+
+ res = dict()
+ for product in self.with_context(prefetch_fields=False):
+ product_id = product.id
+ if not product_id:
+ res[product_id] = dict.fromkeys(
+ ['qty_available', 'free_qty', 'incoming_qty', 'outgoing_qty', 'virtual_available'],
+ 0.0,
+ )
+ continue
+ rounding = product.uom_id.rounding
+ res[product_id] = {}
+ if dates_in_the_past:
+ qty_available = quants_res.get(product_id, [0.0])[0] - moves_in_res_past.get(product_id, 0.0) + moves_out_res_past.get(product_id, 0.0)
+ else:
+ qty_available = quants_res.get(product_id, [0.0])[0]
+ reserved_quantity = quants_res.get(product_id, [False, 0.0])[1]
+ res[product_id]['qty_available'] = float_round(qty_available, precision_rounding=rounding)
+ res[product_id]['free_qty'] = float_round(qty_available - reserved_quantity, precision_rounding=rounding)
+ res[product_id]['incoming_qty'] = float_round(moves_in_res.get(product_id, 0.0), precision_rounding=rounding)
+ res[product_id]['outgoing_qty'] = float_round(moves_out_res.get(product_id, 0.0), precision_rounding=rounding)
+ res[product_id]['virtual_available'] = float_round(
+ qty_available + res[product_id]['incoming_qty'] - res[product_id]['outgoing_qty'],
+ precision_rounding=rounding)
+
+ return res
+
+ def get_components(self):
+ self.ensure_one()
+ return self.ids
+
+ def _get_description(self, picking_type_id):
+ """ return product receipt/delivery/picking description depending on
+ picking type passed as argument.
+ """
+ self.ensure_one()
+ picking_code = picking_type_id.code
+ description = self.description or self.name
+ if picking_code == 'incoming':
+ return self.description_pickingin or description
+ if picking_code == 'outgoing':
+ return self.description_pickingout or self.name
+ if picking_code == 'internal':
+ return self.description_picking or description
+
+ def _get_domain_locations(self):
+ '''
+ Parses the context and returns a list of location_ids based on it.
+ It will return all stock locations when no parameters are given
+ Possible parameters are shop, warehouse, location, compute_child
+ '''
+ Warehouse = self.env['stock.warehouse']
+
+ def _search_ids(model, values):
+ ids = set()
+ domain = []
+ for item in values:
+ if isinstance(item, int):
+ ids.add(item)
+ else:
+ domain = expression.OR([[('name', 'ilike', item)], domain])
+ if domain:
+ ids |= set(self.env[model].search(domain).ids)
+ return ids
+
+ # We may receive a location or warehouse from the context, either by explicit
+ # python code or by the use of dummy fields in the search view.
+ # Normalize them into a list.
+ location = self.env.context.get('location')
+ if location and not isinstance(location, list):
+ location = [location]
+ warehouse = self.env.context.get('warehouse')
+ if warehouse and not isinstance(warehouse, list):
+ warehouse = [warehouse]
+ # filter by location and/or warehouse
+ if warehouse:
+ w_ids = set(Warehouse.browse(_search_ids('stock.warehouse', warehouse)).mapped('view_location_id').ids)
+ if location:
+ l_ids = _search_ids('stock.location', location)
+ location_ids = w_ids & l_ids
+ else:
+ location_ids = w_ids
+ else:
+ if location:
+ location_ids = _search_ids('stock.location', location)
+ else:
+ location_ids = set(Warehouse.search([]).mapped('view_location_id').ids)
+
+ return self._get_domain_locations_new(location_ids, compute_child=self.env.context.get('compute_child', True))
+
+ def _get_domain_locations_new(self, location_ids, company_id=False, compute_child=True):
+ operator = compute_child and 'child_of' or 'in'
+ domain = company_id and ['&', ('company_id', '=', company_id)] or []
+ locations = self.env['stock.location'].browse(location_ids)
+ # TDE FIXME: should move the support of child_of + auto_join directly in expression
+ hierarchical_locations = locations if operator == 'child_of' else locations.browse()
+ other_locations = locations - hierarchical_locations
+ loc_domain = []
+ dest_loc_domain = []
+ # this optimizes [('location_id', 'child_of', hierarchical_locations.ids)]
+ # by avoiding the ORM to search for children locations and injecting a
+ # lot of location ids into the main query
+ for location in hierarchical_locations:
+ loc_domain = loc_domain and ['|'] + loc_domain or loc_domain
+ loc_domain.append(('location_id.parent_path', '=like', location.parent_path + '%'))
+ dest_loc_domain = dest_loc_domain and ['|'] + dest_loc_domain or dest_loc_domain
+ dest_loc_domain.append(('location_dest_id.parent_path', '=like', location.parent_path + '%'))
+ if other_locations:
+ loc_domain = loc_domain and ['|'] + loc_domain or loc_domain
+ loc_domain = loc_domain + [('location_id', operator, other_locations.ids)]
+ dest_loc_domain = dest_loc_domain and ['|'] + dest_loc_domain or dest_loc_domain
+ dest_loc_domain = dest_loc_domain + [('location_dest_id', operator, other_locations.ids)]
+ return (
+ domain + loc_domain,
+ domain + dest_loc_domain + ['!'] + loc_domain if loc_domain else domain + dest_loc_domain,
+ domain + loc_domain + ['!'] + dest_loc_domain if dest_loc_domain else domain + loc_domain
+ )
+
+ def _search_qty_available(self, operator, value):
+ # In the very specific case we want to retrieve products with stock available, we only need
+ # to use the quants, not the stock moves. Therefore, we bypass the usual
+ # '_search_product_quantity' method and call '_search_qty_available_new' instead. This
+ # allows better performances.
+ if not ({'from_date', 'to_date'} & set(self.env.context.keys())):
+ product_ids = self._search_qty_available_new(
+ operator, value, self.env.context.get('lot_id'), self.env.context.get('owner_id'),
+ self.env.context.get('package_id')
+ )
+ return [('id', 'in', product_ids)]
+ return self._search_product_quantity(operator, value, 'qty_available')
+
+ def _search_virtual_available(self, operator, value):
+ # TDE FIXME: should probably clean the search methods
+ return self._search_product_quantity(operator, value, 'virtual_available')
+
+ def _search_incoming_qty(self, operator, value):
+ # TDE FIXME: should probably clean the search methods
+ return self._search_product_quantity(operator, value, 'incoming_qty')
+
+ def _search_outgoing_qty(self, operator, value):
+ # TDE FIXME: should probably clean the search methods
+ return self._search_product_quantity(operator, value, 'outgoing_qty')
+
+ def _search_free_qty(self, operator, value):
+ return self._search_product_quantity(operator, value, 'free_qty')
+
+ def _search_product_quantity(self, operator, value, field):
+ # TDE FIXME: should probably clean the search methods
+ # to prevent sql injections
+ if field not in ('qty_available', 'virtual_available', 'incoming_qty', 'outgoing_qty', 'free_qty'):
+ raise UserError(_('Invalid domain left operand %s', field))
+ if operator not in ('<', '>', '=', '!=', '<=', '>='):
+ raise UserError(_('Invalid domain operator %s', operator))
+ if not isinstance(value, (float, int)):
+ raise UserError(_('Invalid domain right operand %s', value))
+
+ # TODO: Still optimization possible when searching virtual quantities
+ ids = []
+ # Order the search on `id` to prevent the default order on the product name which slows
+ # down the search because of the join on the translation table to get the translated names.
+ for product in self.with_context(prefetch_fields=False).search([], order='id'):
+ if OPERATORS[operator](product[field], value):
+ ids.append(product.id)
+ return [('id', 'in', ids)]
+
+ def _search_qty_available_new(self, operator, value, lot_id=False, owner_id=False, package_id=False):
+ ''' Optimized method which doesn't search on stock.moves, only on stock.quants. '''
+ product_ids = set()
+ domain_quant = self._get_domain_locations()[0]
+ if lot_id:
+ domain_quant.append(('lot_id', '=', lot_id))
+ if owner_id:
+ domain_quant.append(('owner_id', '=', owner_id))
+ if package_id:
+ domain_quant.append(('package_id', '=', package_id))
+ quants_groupby = self.env['stock.quant'].read_group(domain_quant, ['product_id', 'quantity'], ['product_id'], orderby='id')
+
+ # check if we need include zero values in result
+ include_zero = (
+ value < 0.0 and operator in ('>', '>=') or
+ value > 0.0 and operator in ('<', '<=') or
+ value == 0.0 and operator in ('>=', '<=', '=')
+ )
+
+ processed_product_ids = set()
+ for quant in quants_groupby:
+ product_id = quant['product_id'][0]
+ if include_zero:
+ processed_product_ids.add(product_id)
+ if OPERATORS[operator](quant['quantity'], value):
+ product_ids.add(product_id)
+
+ if include_zero:
+ products_without_quants_in_domain = self.env['product.product'].search([
+ ('type', '=', 'product'),
+ ('id', 'not in', list(processed_product_ids))]
+ )
+ product_ids |= set(products_without_quants_in_domain.ids)
+ return list(product_ids)
+
+ def _compute_nbr_reordering_rules(self):
+ read_group_res = self.env['stock.warehouse.orderpoint'].read_group(
+ [('product_id', 'in', self.ids)],
+ ['product_id', 'product_min_qty', 'product_max_qty'],
+ ['product_id'])
+ res = {i: {} for i in self.ids}
+ for data in read_group_res:
+ res[data['product_id'][0]]['nbr_reordering_rules'] = int(data['product_id_count'])
+ res[data['product_id'][0]]['reordering_min_qty'] = data['product_min_qty']
+ res[data['product_id'][0]]['reordering_max_qty'] = data['product_max_qty']
+ for product in self:
+ product_res = res.get(product.id) or {}
+ product.nbr_reordering_rules = product_res.get('nbr_reordering_rules', 0)
+ product.reordering_min_qty = product_res.get('reordering_min_qty', 0)
+ product.reordering_max_qty = product_res.get('reordering_max_qty', 0)
+
+ @api.onchange('tracking')
+ def onchange_tracking(self):
+ products = self.filtered(lambda self: self.tracking and self.tracking != 'none')
+ if products:
+ unassigned_quants = self.env['stock.quant'].search_count([('product_id', 'in', products.ids), ('lot_id', '=', False), ('location_id.usage','=', 'internal')])
+ if unassigned_quants:
+ return {
+ 'warning': {
+ 'title': _('Warning!'),
+ 'message': _("You have product(s) in stock that have no lot/serial number. You can assign lot/serial numbers by doing an inventory adjustment.")}}
+
+ @api.model
+ def view_header_get(self, view_id, view_type):
+ res = super(Product, self).view_header_get(view_id, view_type)
+ if not res and self._context.get('active_id') and self._context.get('active_model') == 'stock.location':
+ return _(
+ 'Products: %(location)s',
+ location=self.env['stock.location'].browse(self._context['active_id']).name,
+ )
+ return res
+
+ @api.model
+ def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
+ res = super(Product, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
+ if self._context.get('location') and isinstance(self._context['location'], int):
+ location = self.env['stock.location'].browse(self._context['location'])
+ fields = res.get('fields')
+ if fields:
+ if location.usage == 'supplier':
+ if fields.get('virtual_available'):
+ res['fields']['virtual_available']['string'] = _('Future Receipts')
+ if fields.get('qty_available'):
+ res['fields']['qty_available']['string'] = _('Received Qty')
+ elif location.usage == 'internal':
+ if fields.get('virtual_available'):
+ res['fields']['virtual_available']['string'] = _('Forecasted Quantity')
+ elif location.usage == 'customer':
+ if fields.get('virtual_available'):
+ res['fields']['virtual_available']['string'] = _('Future Deliveries')
+ if fields.get('qty_available'):
+ res['fields']['qty_available']['string'] = _('Delivered Qty')
+ elif location.usage == 'inventory':
+ if fields.get('virtual_available'):
+ res['fields']['virtual_available']['string'] = _('Future P&L')
+ if fields.get('qty_available'):
+ res['fields']['qty_available']['string'] = _('P&L Qty')
+ elif location.usage == 'production':
+ if fields.get('virtual_available'):
+ res['fields']['virtual_available']['string'] = _('Future Productions')
+ if fields.get('qty_available'):
+ res['fields']['qty_available']['string'] = _('Produced Qty')
+ return res
+
+ def action_view_orderpoints(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("stock.action_orderpoint")
+ action['context'] = literal_eval(action.get('context'))
+ action['context'].pop('search_default_trigger', False)
+ action['context'].update({
+ 'search_default_filter_not_snoozed': True,
+ })
+ if self and len(self) == 1:
+ action['context'].update({
+ 'default_product_id': self.ids[0],
+ 'search_default_product_id': self.ids[0]
+ })
+ else:
+ action['domain'] = expression.AND([action.get('domain', []), [('product_id', 'in', self.ids)]])
+ return action
+
+ def action_view_routes(self):
+ return self.mapped('product_tmpl_id').action_view_routes()
+
+ def action_view_stock_move_lines(self):
+ self.ensure_one()
+ action = self.env["ir.actions.actions"]._for_xml_id("stock.stock_move_line_action")
+ action['domain'] = [('product_id', '=', self.id)]
+ return action
+
+ def action_view_related_putaway_rules(self):
+ self.ensure_one()
+ domain = [
+ '|',
+ ('product_id', '=', self.id),
+ ('category_id', '=', self.product_tmpl_id.categ_id.id),
+ ]
+ return self.env['product.template']._get_action_view_related_putaway_rules(domain)
+
+ def action_open_product_lot(self):
+ self.ensure_one()
+ action = self.env["ir.actions.actions"]._for_xml_id("stock.action_production_lot_form")
+ action['domain'] = [('product_id', '=', self.id)]
+ action['context'] = {
+ 'default_product_id': self.id,
+ 'set_product_readonly': True,
+ 'default_company_id': (self.company_id or self.env.company).id,
+ }
+ return action
+
+ # Be aware that the exact same function exists in product.template
+ def action_open_quants(self):
+ domain = [('product_id', 'in', self.ids)]
+ hide_location = not self.user_has_groups('stock.group_stock_multi_locations')
+ hide_lot = all(product.tracking == 'none' for product in self)
+ self = self.with_context(
+ hide_location=hide_location, hide_lot=hide_lot,
+ no_at_date=True, search_default_on_hand=True,
+ )
+
+ # If user have rights to write on quant, we define the view as editable.
+ if self.user_has_groups('stock.group_stock_manager'):
+ self = self.with_context(inventory_mode=True)
+ # Set default location id if multilocations is inactive
+ if not self.user_has_groups('stock.group_stock_multi_locations'):
+ user_company = self.env.company
+ warehouse = self.env['stock.warehouse'].search(
+ [('company_id', '=', user_company.id)], limit=1
+ )
+ if warehouse:
+ self = self.with_context(default_location_id=warehouse.lot_stock_id.id)
+ # Set default product id if quants concern only one product
+ if len(self) == 1:
+ self = self.with_context(
+ default_product_id=self.id,
+ single_product=True
+ )
+ else:
+ self = self.with_context(product_tmpl_ids=self.product_tmpl_id.ids)
+ action = self.env['stock.quant']._get_quants_action(domain)
+ action["name"] = _('Update Quantity')
+ return action
+
+ def action_update_quantity_on_hand(self):
+ return self.product_tmpl_id.with_context(default_product_id=self.id, create=True).action_update_quantity_on_hand()
+
+ def action_product_forecast_report(self):
+ self.ensure_one()
+ action = self.env["ir.actions.actions"]._for_xml_id("stock.stock_replenishment_product_product_action")
+ return action
+
+ @api.model
+ def get_theoretical_quantity(self, product_id, location_id, lot_id=None, package_id=None, owner_id=None, to_uom=None):
+ product_id = self.env['product.product'].browse(product_id)
+ product_id.check_access_rights('read')
+ product_id.check_access_rule('read')
+
+ location_id = self.env['stock.location'].browse(location_id)
+ lot_id = self.env['stock.production.lot'].browse(lot_id)
+ package_id = self.env['stock.quant.package'].browse(package_id)
+ owner_id = self.env['res.partner'].browse(owner_id)
+ to_uom = self.env['uom.uom'].browse(to_uom)
+ quants = self.env['stock.quant']._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=True)
+ if lot_id:
+ quants = quants.filtered(lambda q: q.lot_id == lot_id)
+ theoretical_quantity = sum([quant.quantity for quant in quants])
+ if to_uom and product_id.uom_id != to_uom:
+ theoretical_quantity = product_id.uom_id._compute_quantity(theoretical_quantity, to_uom)
+ return theoretical_quantity
+
+ def write(self, values):
+ if 'active' in values:
+ self.filtered(lambda p: p.active != values['active']).with_context(active_test=False).orderpoint_ids.write({
+ 'active': values['active']
+ })
+ return super().write(values)
+
+ def _get_quantity_in_progress(self, location_ids=False, warehouse_ids=False):
+ return defaultdict(float), defaultdict(float)
+
+ def _get_rules_from_location(self, location, route_ids=False, seen_rules=False):
+ if not seen_rules:
+ seen_rules = self.env['stock.rule']
+ rule = self.env['procurement.group']._get_rule(self, location, {
+ 'route_ids': route_ids,
+ 'warehouse_id': location.get_warehouse()
+ })
+ if not rule:
+ return seen_rules
+ if rule.procure_method == 'make_to_stock' or rule.action not in ('pull_push', 'pull'):
+ return seen_rules | rule
+ else:
+ return self._get_rules_from_location(rule.location_src_id, seen_rules=seen_rules | rule)
+
+
+ def _filter_to_unlink(self):
+ domain = [('product_id', 'in', self.ids)]
+ lines = self.env['stock.production.lot'].read_group(domain, ['product_id'], ['product_id'])
+ linked_product_ids = [group['product_id'][0] for group in lines]
+ return super(Product, self - self.browse(linked_product_ids))._filter_to_unlink()
+
+
+class ProductTemplate(models.Model):
+ _inherit = 'product.template'
+ _check_company_auto = True
+
+ responsible_id = fields.Many2one(
+ 'res.users', string='Responsible', default=lambda self: self.env.uid, company_dependent=True, check_company=True,
+ help="This user will be responsible of the next activities related to logistic operations for this product.")
+ type = fields.Selection(selection_add=[
+ ('product', 'Storable Product')
+ ], tracking=True, ondelete={'product': 'set default'})
+ property_stock_production = fields.Many2one(
+ 'stock.location', "Production Location",
+ company_dependent=True, check_company=True, domain="[('usage', '=', 'production'), '|', ('company_id', '=', False), ('company_id', '=', allowed_company_ids[0])]",
+ help="This stock location will be used, instead of the default one, as the source location for stock moves generated by manufacturing orders.")
+ property_stock_inventory = fields.Many2one(
+ 'stock.location', "Inventory Location",
+ company_dependent=True, check_company=True, domain="[('usage', '=', 'inventory'), '|', ('company_id', '=', False), ('company_id', '=', allowed_company_ids[0])]",
+ help="This stock location will be used, instead of the default one, as the source location for stock moves generated when you do an inventory.")
+ sale_delay = fields.Float(
+ 'Customer Lead Time', default=0,
+ help="Delivery lead time, in days. It's the number of days, promised to the customer, between the confirmation of the sales order and the delivery.")
+ tracking = fields.Selection([
+ ('serial', 'By Unique Serial Number'),
+ ('lot', 'By Lots'),
+ ('none', 'No Tracking')], string="Tracking", help="Ensure the traceability of a storable product in your warehouse.", default='none', required=True)
+ description_picking = fields.Text('Description on Picking', translate=True)
+ description_pickingout = fields.Text('Description on Delivery Orders', translate=True)
+ description_pickingin = fields.Text('Description on Receptions', translate=True)
+ qty_available = fields.Float(
+ 'Quantity On Hand', compute='_compute_quantities', search='_search_qty_available',
+ compute_sudo=False, digits='Product Unit of Measure')
+ virtual_available = fields.Float(
+ 'Forecasted Quantity', compute='_compute_quantities', search='_search_virtual_available',
+ compute_sudo=False, digits='Product Unit of Measure')
+ incoming_qty = fields.Float(
+ 'Incoming', compute='_compute_quantities', search='_search_incoming_qty',
+ compute_sudo=False, digits='Product Unit of Measure')
+ outgoing_qty = fields.Float(
+ 'Outgoing', compute='_compute_quantities', search='_search_outgoing_qty',
+ compute_sudo=False, digits='Product Unit of Measure')
+ # The goal of these fields is to be able to put some keys in context from search view in order
+ # to influence computed field.
+ location_id = fields.Many2one('stock.location', 'Location', store=False)
+ warehouse_id = fields.Many2one('stock.warehouse', 'Warehouse', store=False)
+ has_available_route_ids = fields.Boolean(
+ 'Routes can be selected on this product', compute='_compute_has_available_route_ids',
+ default=lambda self: self.env['stock.location.route'].search_count([('product_selectable', '=', True)]))
+ route_ids = fields.Many2many(
+ 'stock.location.route', 'stock_route_product', 'product_id', 'route_id', 'Routes',
+ domain=[('product_selectable', '=', True)],
+ help="Depending on the modules installed, this will allow you to define the route of the product: whether it will be bought, manufactured, replenished on order, etc.")
+ nbr_reordering_rules = fields.Integer('Reordering Rules',
+ compute='_compute_nbr_reordering_rules', compute_sudo=False)
+ reordering_min_qty = fields.Float(
+ compute='_compute_nbr_reordering_rules', compute_sudo=False)
+ reordering_max_qty = fields.Float(
+ compute='_compute_nbr_reordering_rules', compute_sudo=False)
+ # TDE FIXME: seems only visible in a view - remove me ?
+ route_from_categ_ids = fields.Many2many(
+ relation="stock.location.route", string="Category Routes",
+ related='categ_id.total_route_ids', readonly=False, related_sudo=False)
+
+ @api.depends('type')
+ def _compute_has_available_route_ids(self):
+ self.has_available_route_ids = self.env['stock.location.route'].search_count([('product_selectable', '=', True)])
+
+ @api.depends(
+ 'product_variant_ids',
+ 'product_variant_ids.stock_move_ids.product_qty',
+ 'product_variant_ids.stock_move_ids.state',
+ )
+ @api.depends_context('company', 'location', 'warehouse')
+ def _compute_quantities(self):
+ res = self._compute_quantities_dict()
+ for template in self:
+ template.qty_available = res[template.id]['qty_available']
+ template.virtual_available = res[template.id]['virtual_available']
+ template.incoming_qty = res[template.id]['incoming_qty']
+ template.outgoing_qty = res[template.id]['outgoing_qty']
+
+ def _product_available(self, name, arg):
+ return self._compute_quantities_dict()
+
+ def _compute_quantities_dict(self):
+ # TDE FIXME: why not using directly the function fields ?
+ variants_available = self.mapped('product_variant_ids')._product_available()
+ prod_available = {}
+ for template in self:
+ qty_available = 0
+ virtual_available = 0
+ incoming_qty = 0
+ outgoing_qty = 0
+ for p in template.product_variant_ids:
+ qty_available += variants_available[p.id]["qty_available"]
+ virtual_available += variants_available[p.id]["virtual_available"]
+ incoming_qty += variants_available[p.id]["incoming_qty"]
+ outgoing_qty += variants_available[p.id]["outgoing_qty"]
+ prod_available[template.id] = {
+ "qty_available": qty_available,
+ "virtual_available": virtual_available,
+ "incoming_qty": incoming_qty,
+ "outgoing_qty": outgoing_qty,
+ }
+ return prod_available
+
+ @api.model
+ def _get_action_view_related_putaway_rules(self, domain):
+ return {
+ 'name': _('Putaway Rules'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'stock.putaway.rule',
+ 'view_mode': 'list',
+ 'domain': domain,
+ }
+
+ def _search_qty_available(self, operator, value):
+ domain = [('qty_available', operator, value)]
+ product_variant_ids = self.env['product.product'].search(domain)
+ return [('product_variant_ids', 'in', product_variant_ids.ids)]
+
+ def _search_virtual_available(self, operator, value):
+ domain = [('virtual_available', operator, value)]
+ product_variant_ids = self.env['product.product'].search(domain)
+ return [('product_variant_ids', 'in', product_variant_ids.ids)]
+
+ def _search_incoming_qty(self, operator, value):
+ domain = [('incoming_qty', operator, value)]
+ product_variant_ids = self.env['product.product'].search(domain)
+ return [('product_variant_ids', 'in', product_variant_ids.ids)]
+
+ def _search_outgoing_qty(self, operator, value):
+ domain = [('outgoing_qty', operator, value)]
+ product_variant_ids = self.env['product.product'].search(domain)
+ return [('product_variant_ids', 'in', product_variant_ids.ids)]
+
+ def _compute_nbr_reordering_rules(self):
+ res = {k: {'nbr_reordering_rules': 0, 'reordering_min_qty': 0, 'reordering_max_qty': 0} for k in self.ids}
+ product_data = self.env['stock.warehouse.orderpoint'].read_group([('product_id.product_tmpl_id', 'in', self.ids)], ['product_id', 'product_min_qty', 'product_max_qty'], ['product_id'])
+ for data in product_data:
+ product = self.env['product.product'].browse([data['product_id'][0]])
+ product_tmpl_id = product.product_tmpl_id.id
+ res[product_tmpl_id]['nbr_reordering_rules'] += int(data['product_id_count'])
+ res[product_tmpl_id]['reordering_min_qty'] = data['product_min_qty']
+ res[product_tmpl_id]['reordering_max_qty'] = data['product_max_qty']
+ for template in self:
+ if not template.id:
+ template.nbr_reordering_rules = 0
+ template.reordering_min_qty = 0
+ template.reordering_max_qty = 0
+ continue
+ template.nbr_reordering_rules = res[template.id]['nbr_reordering_rules']
+ template.reordering_min_qty = res[template.id]['reordering_min_qty']
+ template.reordering_max_qty = res[template.id]['reordering_max_qty']
+
+ @api.onchange('tracking')
+ def onchange_tracking(self):
+ return self.mapped('product_variant_ids').onchange_tracking()
+
+ @api.onchange('type')
+ def _onchange_type(self):
+ res = super(ProductTemplate, self)._onchange_type() or {}
+ if self.type == 'consu' and self.tracking != 'none':
+ self.tracking = 'none'
+
+ # Return a warning when trying to change the product type
+ if self.ids and self.product_variant_ids.ids and self.env['stock.move.line'].sudo().search_count([
+ ('product_id', 'in', self.product_variant_ids.ids), ('state', '!=', 'cancel')
+ ]):
+ res['warning'] = {
+ 'title': _('Warning!'),
+ 'message': _(
+ 'This product has been used in at least one inventory movement. '
+ 'It is not advised to change the Product Type since it can lead to inconsistencies. '
+ 'A better solution could be to archive the product and create a new one instead.'
+ )
+ }
+ return res
+
+ def write(self, vals):
+ if 'uom_id' in vals:
+ new_uom = self.env['uom.uom'].browse(vals['uom_id'])
+ updated = self.filtered(lambda template: template.uom_id != new_uom)
+ done_moves = self.env['stock.move'].search([('product_id', 'in', updated.with_context(active_test=False).mapped('product_variant_ids').ids)], limit=1)
+ if done_moves:
+ raise UserError(_("You cannot change the unit of measure as there are already stock moves for this product. If you want to change the unit of measure, you should rather archive this product and create a new one."))
+ if 'type' in vals and vals['type'] != 'product' and sum(self.mapped('nbr_reordering_rules')) != 0:
+ raise UserError(_('You still have some active reordering rules on this product. Please archive or delete them first.'))
+ if any('type' in vals and vals['type'] != prod_tmpl.type for prod_tmpl in self):
+ existing_move_lines = self.env['stock.move.line'].search([
+ ('product_id', 'in', self.mapped('product_variant_ids').ids),
+ ('state', 'in', ['partially_available', 'assigned']),
+ ])
+ if existing_move_lines:
+ raise UserError(_("You can not change the type of a product that is currently reserved on a stock move. If you need to change the type, you should first unreserve the stock move."))
+ if 'type' in vals and vals['type'] != 'product' and any(p.type == 'product' and not float_is_zero(p.qty_available, precision_rounding=p.uom_id.rounding) for p in self):
+ raise UserError(_("Available quantity should be set to zero before changing type"))
+ return super(ProductTemplate, self).write(vals)
+
+ # Be aware that the exact same function exists in product.product
+ def action_open_quants(self):
+ return self.product_variant_ids.filtered(lambda p: p.active or p.qty_available != 0).action_open_quants()
+
+ def action_update_quantity_on_hand(self):
+ advanced_option_groups = [
+ 'stock.group_stock_multi_locations',
+ 'stock.group_production_lot',
+ 'stock.group_tracking_owner',
+ 'product.group_stock_packaging'
+ ]
+ if (self.env.user.user_has_groups(','.join(advanced_option_groups))):
+ return self.action_open_quants()
+ else:
+ default_product_id = self.env.context.get('default_product_id', len(self.product_variant_ids) == 1 and self.product_variant_id.id)
+ action = self.env["ir.actions.actions"]._for_xml_id("stock.action_change_product_quantity")
+ action['context'] = dict(
+ self.env.context,
+ default_product_id=default_product_id,
+ default_product_tmpl_id=self.id
+ )
+ return action
+
+ def action_view_related_putaway_rules(self):
+ self.ensure_one()
+ domain = [
+ '|',
+ ('product_id.product_tmpl_id', '=', self.id),
+ ('category_id', '=', self.categ_id.id),
+ ]
+ return self._get_action_view_related_putaway_rules(domain)
+
+ def action_view_orderpoints(self):
+ return self.product_variant_ids.action_view_orderpoints()
+
+ def action_view_stock_move_lines(self):
+ self.ensure_one()
+ action = self.env["ir.actions.actions"]._for_xml_id("stock.stock_move_line_action")
+ action['domain'] = [('product_id.product_tmpl_id', 'in', self.ids)]
+ return action
+
+ def action_open_product_lot(self):
+ self.ensure_one()
+ action = self.env["ir.actions.actions"]._for_xml_id("stock.action_production_lot_form")
+ action['domain'] = [('product_id.product_tmpl_id', '=', self.id)]
+ action['context'] = {
+ 'default_product_tmpl_id': self.id,
+ 'default_company_id': (self.company_id or self.env.company).id,
+ }
+ if self.product_variant_count == 1:
+ action['context'].update({
+ 'default_product_id': self.product_variant_id.id,
+ })
+ return action
+
+ def action_open_routes_diagram(self):
+ products = False
+ if self.env.context.get('default_product_id'):
+ products = self.env['product.product'].browse(self.env.context['default_product_id'])
+ if not products and self.env.context.get('default_product_tmpl_id'):
+ products = self.env['product.template'].browse(self.env.context['default_product_tmpl_id']).product_variant_ids
+ if not self.user_has_groups('stock.group_stock_multi_warehouses') and len(products) == 1:
+ company = products.company_id or self.env.company
+ warehouse = self.env['stock.warehouse'].search([('company_id', '=', company.id)], limit=1)
+ return self.env.ref('stock.action_report_stock_rule').report_action(None, data={
+ 'product_id': products.id,
+ 'warehouse_ids': warehouse.ids,
+ }, config=False)
+ action = self.env["ir.actions.actions"]._for_xml_id("stock.action_stock_rules_report")
+ action['context'] = self.env.context
+ return action
+
+ def action_product_tmpl_forecast_report(self):
+ self.ensure_one()
+ action = self.env["ir.actions.actions"]._for_xml_id('stock.stock_replenishment_product_product_action')
+ return action
+
+class ProductCategory(models.Model):
+ _inherit = 'product.category'
+
+ route_ids = fields.Many2many(
+ 'stock.location.route', 'stock_location_route_categ', 'categ_id', 'route_id', 'Routes',
+ domain=[('product_categ_selectable', '=', True)])
+ removal_strategy_id = fields.Many2one(
+ 'product.removal', 'Force Removal Strategy',
+ help="Set a specific removal strategy that will be used regardless of the source location for this product category")
+ total_route_ids = fields.Many2many(
+ 'stock.location.route', string='Total routes', compute='_compute_total_route_ids',
+ readonly=True)
+ putaway_rule_ids = fields.One2many('stock.putaway.rule', 'category_id', 'Putaway Rules')
+
+ def _compute_total_route_ids(self):
+ for category in self:
+ base_cat = category
+ routes = category.route_ids
+ while base_cat.parent_id:
+ base_cat = base_cat.parent_id
+ routes |= base_cat.route_ids
+ category.total_route_ids = routes
+
+
+class UoM(models.Model):
+ _inherit = 'uom.uom'
+
+ def write(self, values):
+ # Users can not update the factor if open stock moves are based on it
+ if 'factor' in values or 'factor_inv' in values or 'category_id' in values:
+ changed = self.filtered(
+ lambda u: any(u[f] != values[f] if f in values else False
+ for f in {'factor', 'factor_inv'})) + self.filtered(
+ lambda u: any(u[f].id != int(values[f]) if f in values else False
+ for f in {'category_id'}))
+ if changed:
+ error_msg = _(
+ "You cannot change the ratio of this unit of measure"
+ " as some products with this UoM have already been moved"
+ " or are currently reserved."
+ )
+ if self.env['stock.move'].sudo().search_count([
+ ('product_uom', 'in', changed.ids),
+ ('state', 'not in', ('cancel', 'done'))
+ ]):
+ raise UserError(error_msg)
+ if self.env['stock.move.line'].sudo().search_count([
+ ('product_uom_id', 'in', changed.ids),
+ ('state', 'not in', ('cancel', 'done')),
+ ]):
+ raise UserError(error_msg)
+ if self.env['stock.quant'].sudo().search_count([
+ ('product_id.product_tmpl_id.uom_id', 'in', changed.ids),
+ ('quantity', '!=', 0),
+ ]):
+ raise UserError(error_msg)
+ return super(UoM, self).write(values)
+
+ def _adjust_uom_quantities(self, qty, quant_uom):
+ """ This method adjust the quantities of a procurement if its UoM isn't the same
+ as the one of the quant and the parameter 'propagate_uom' is not set.
+ """
+ procurement_uom = self
+ computed_qty = qty
+ get_param = self.env['ir.config_parameter'].sudo().get_param
+ if get_param('stock.propagate_uom') != '1':
+ computed_qty = self._compute_quantity(qty, quant_uom, rounding_method='HALF-UP')
+ procurement_uom = quant_uom
+ else:
+ computed_qty = self._compute_quantity(qty, procurement_uom, rounding_method='HALF-UP')
+ return (computed_qty, procurement_uom)
diff --git a/addons/stock/models/product_strategy.py b/addons/stock/models/product_strategy.py
new file mode 100644
index 00000000..5606fb9f
--- /dev/null
+++ b/addons/stock/models/product_strategy.py
@@ -0,0 +1,88 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+
+class RemovalStrategy(models.Model):
+ _name = 'product.removal'
+ _description = 'Removal Strategy'
+
+ name = fields.Char('Name', required=True)
+ method = fields.Char("Method", required=True, help="FIFO, LIFO...")
+
+
+class StockPutawayRule(models.Model):
+ _name = 'stock.putaway.rule'
+ _order = 'sequence,product_id'
+ _description = 'Putaway Rule'
+ _check_company_auto = True
+
+ def _default_category_id(self):
+ if self.env.context.get('active_model') == 'product.category':
+ return self.env.context.get('active_id')
+
+ def _default_location_id(self):
+ if self.env.context.get('active_model') == 'stock.location':
+ return self.env.context.get('active_id')
+
+ def _default_product_id(self):
+ if self.env.context.get('active_model') == 'product.template' and self.env.context.get('active_id'):
+ product_template = self.env['product.template'].browse(self.env.context.get('active_id'))
+ product_template = product_template.exists()
+ if product_template.product_variant_count == 1:
+ return product_template.product_variant_id
+ elif self.env.context.get('active_model') == 'product.product':
+ return self.env.context.get('active_id')
+
+ def _domain_category_id(self):
+ active_model = self.env.context.get('active_model')
+ if active_model in ('product.template', 'product.product') and self.env.context.get('active_id'):
+ product = self.env[active_model].browse(self.env.context.get('active_id'))
+ product = product.exists()
+ if product:
+ return [('id', '=', product.categ_id.id)]
+ return []
+
+ def _domain_product_id(self):
+ domain = "[('type', '!=', 'service'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]"
+ if self.env.context.get('active_model') == 'product.template':
+ return [('product_tmpl_id', '=', self.env.context.get('active_id'))]
+ return domain
+
+ product_id = fields.Many2one(
+ 'product.product', 'Product', check_company=True,
+ default=_default_product_id, domain=_domain_product_id, ondelete='cascade')
+ category_id = fields.Many2one('product.category', 'Product Category',
+ default=_default_category_id, domain=_domain_category_id, ondelete='cascade')
+ location_in_id = fields.Many2one(
+ 'stock.location', 'When product arrives in', check_company=True,
+ domain="[('child_ids', '!=', False), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
+ default=_default_location_id, required=True, ondelete='cascade')
+ location_out_id = fields.Many2one(
+ 'stock.location', 'Store to', check_company=True,
+ domain="[('id', 'child_of', location_in_id), ('id', '!=', location_in_id), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
+ required=True, ondelete='cascade')
+ sequence = fields.Integer('Priority', help="Give to the more specialized category, a higher priority to have them in top of the list.")
+ company_id = fields.Many2one(
+ 'res.company', 'Company', required=True,
+ default=lambda s: s.env.company.id, index=True)
+
+ @api.onchange('location_in_id')
+ def _onchange_location_in(self):
+ if self.location_out_id:
+ child_location_count = self.env['stock.location'].search_count([
+ ('id', '=', self.location_out_id.id),
+ ('id', 'child_of', self.location_in_id.id),
+ ('id', '!=', self.location_in_id.id),
+ ])
+ if not child_location_count:
+ self.location_out_id = None
+
+ def write(self, vals):
+ if 'company_id' in vals:
+ for rule in self:
+ if rule.company_id.id != vals['company_id']:
+ raise UserError(_("Changing the company of this record is forbidden at this point, you should rather archive it and create a new one."))
+ return super(StockPutawayRule, self).write(vals)
diff --git a/addons/stock/models/res_company.py b/addons/stock/models/res_company.py
new file mode 100644
index 00000000..93a960ed
--- /dev/null
+++ b/addons/stock/models/res_company.py
@@ -0,0 +1,188 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import _, api, fields, models
+
+
+class Company(models.Model):
+ _inherit = "res.company"
+ _check_company_auto = True
+
+ def _default_confirmation_mail_template(self):
+ try:
+ return self.env.ref('stock.mail_template_data_delivery_confirmation').id
+ except ValueError:
+ return False
+
+ internal_transit_location_id = fields.Many2one(
+ 'stock.location', 'Internal Transit Location', ondelete="restrict", check_company=True,
+ help="Technical field used for resupply routes between warehouses that belong to this company")
+ stock_move_email_validation = fields.Boolean("Email Confirmation picking", default=False)
+ stock_mail_confirmation_template_id = fields.Many2one('mail.template', string="Email Template confirmation picking",
+ domain="[('model', '=', 'stock.picking')]",
+ default=_default_confirmation_mail_template,
+ help="Email sent to the customer once the order is done.")
+
+ def _create_transit_location(self):
+ '''Create a transit location with company_id being the given company_id. This is needed
+ in case of resuply routes between warehouses belonging to the same company, because
+ we don't want to create accounting entries at that time.
+ '''
+ parent_location = self.env.ref('stock.stock_location_locations', raise_if_not_found=False)
+ for company in self:
+ location = self.env['stock.location'].create({
+ 'name': _('Inter-warehouse transit'),
+ 'usage': 'transit',
+ 'location_id': parent_location and parent_location.id or False,
+ 'company_id': company.id,
+ 'active': False
+ })
+
+ company.write({'internal_transit_location_id': location.id})
+
+ company.partner_id.with_company(company).write({
+ 'property_stock_customer': location.id,
+ 'property_stock_supplier': location.id,
+ })
+
+ def _create_inventory_loss_location(self):
+ parent_location = self.env.ref('stock.stock_location_locations_virtual', raise_if_not_found=False)
+ for company in self:
+ inventory_loss_location = self.env['stock.location'].create({
+ 'name': 'Inventory adjustment',
+ 'usage': 'inventory',
+ 'location_id': parent_location.id,
+ 'company_id': company.id,
+ })
+ self.env['ir.property']._set_default(
+ "property_stock_inventory",
+ "product.template",
+ inventory_loss_location,
+ company.id,
+ )
+
+ def _create_production_location(self):
+ parent_location = self.env.ref('stock.stock_location_locations_virtual', raise_if_not_found=False)
+ for company in self:
+ production_location = self.env['stock.location'].create({
+ 'name': 'Production',
+ 'usage': 'production',
+ 'location_id': parent_location.id,
+ 'company_id': company.id,
+ })
+ self.env['ir.property']._set_default(
+ "property_stock_production",
+ "product.template",
+ production_location,
+ company.id,
+ )
+
+
+ def _create_scrap_location(self):
+ parent_location = self.env.ref('stock.stock_location_locations_virtual', raise_if_not_found=False)
+ for company in self:
+ scrap_location = self.env['stock.location'].create({
+ 'name': 'Scrap',
+ 'usage': 'inventory',
+ 'location_id': parent_location.id,
+ 'company_id': company.id,
+ 'scrap_location': True,
+ })
+
+ def _create_scrap_sequence(self):
+ scrap_vals = []
+ for company in self:
+ scrap_vals.append({
+ 'name': '%s Sequence scrap' % company.name,
+ 'code': 'stock.scrap',
+ 'company_id': company.id,
+ 'prefix': 'SP/',
+ 'padding': 5,
+ 'number_next': 1,
+ 'number_increment': 1
+ })
+ if scrap_vals:
+ self.env['ir.sequence'].create(scrap_vals)
+
+ @api.model
+ def create_missing_warehouse(self):
+ """ This hook is used to add a warehouse on existing companies
+ when module stock is installed.
+ """
+ company_ids = self.env['res.company'].search([])
+ company_with_warehouse = self.env['stock.warehouse'].with_context(active_test=False).search([]).mapped('company_id')
+ company_without_warehouse = company_ids - company_with_warehouse
+ for company in company_without_warehouse:
+ self.env['stock.warehouse'].create({
+ 'name': company.name,
+ 'code': company.name[:5],
+ 'company_id': company.id,
+ 'partner_id': company.partner_id.id,
+ })
+
+ @api.model
+ def create_missing_transit_location(self):
+ company_without_transit = self.env['res.company'].search([('internal_transit_location_id', '=', False)])
+ company_without_transit._create_transit_location()
+
+ @api.model
+ def create_missing_inventory_loss_location(self):
+ company_ids = self.env['res.company'].search([])
+ inventory_loss_product_template_field = self.env['ir.model.fields']._get('product.template', 'property_stock_inventory')
+ companies_having_property = self.env['ir.property'].sudo().search([('fields_id', '=', inventory_loss_product_template_field.id)]).mapped('company_id')
+ company_without_property = company_ids - companies_having_property
+ company_without_property._create_inventory_loss_location()
+
+ @api.model
+ def create_missing_production_location(self):
+ company_ids = self.env['res.company'].search([])
+ production_product_template_field = self.env['ir.model.fields']._get('product.template', 'property_stock_production')
+ companies_having_property = self.env['ir.property'].sudo().search([('fields_id', '=', production_product_template_field.id)]).mapped('company_id')
+ company_without_property = company_ids - companies_having_property
+ company_without_property._create_production_location()
+
+ @api.model
+ def create_missing_scrap_location(self):
+ company_ids = self.env['res.company'].search([])
+ companies_having_scrap_loc = self.env['stock.location'].search([('scrap_location', '=', True)]).mapped('company_id')
+ company_without_property = company_ids - companies_having_scrap_loc
+ company_without_property._create_scrap_location()
+
+ @api.model
+ def create_missing_scrap_sequence(self):
+ company_ids = self.env['res.company'].search([])
+ company_has_scrap_seq = self.env['ir.sequence'].search([('code', '=', 'stock.scrap')]).mapped('company_id')
+ company_todo_sequence = company_ids - company_has_scrap_seq
+ company_todo_sequence._create_scrap_sequence()
+
+ def _create_per_company_locations(self):
+ self.ensure_one()
+ self._create_transit_location()
+ self._create_inventory_loss_location()
+ self._create_production_location()
+ self._create_scrap_location()
+
+ def _create_per_company_sequences(self):
+ self.ensure_one()
+ self._create_scrap_sequence()
+
+ def _create_per_company_picking_types(self):
+ self.ensure_one()
+
+ def _create_per_company_rules(self):
+ self.ensure_one()
+
+ @api.model
+ def create(self, vals):
+ company = super(Company, self).create(vals)
+ company.sudo()._create_per_company_locations()
+ company.sudo()._create_per_company_sequences()
+ company.sudo()._create_per_company_picking_types()
+ company.sudo()._create_per_company_rules()
+ self.env['stock.warehouse'].sudo().create({
+ 'name': company.name,
+ 'code': self.env.context.get('default_code') or company.name[:5],
+ 'company_id': company.id,
+ 'partner_id': company.partner_id.id
+ })
+ return company
diff --git a/addons/stock/models/res_config_settings.py b/addons/stock/models/res_config_settings.py
new file mode 100644
index 00000000..e3b19ce0
--- /dev/null
+++ b/addons/stock/models/res_config_settings.py
@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ module_procurement_jit = fields.Selection([
+ ('1', 'Immediately after sales order confirmation'),
+ ('0', 'Manually or based on automatic scheduler')
+ ], "Reservation", default='0',
+ help="Reserving products manually in delivery orders or by running the scheduler is advised to better manage priorities in case of long customer lead times or/and frequent stock-outs.")
+ module_product_expiry = fields.Boolean("Expiration Dates",
+ help="Track following dates on lots & serial numbers: best before, removal, end of life, alert. \n Such dates are set automatically at lot/serial number creation based on values set on the product (in days).")
+ group_stock_production_lot = fields.Boolean("Lots & Serial Numbers",
+ implied_group='stock.group_production_lot')
+ group_lot_on_delivery_slip = fields.Boolean("Display Lots & Serial Numbers on Delivery Slips",
+ implied_group='stock.group_lot_on_delivery_slip', group="base.group_user,base.group_portal")
+ group_stock_tracking_lot = fields.Boolean("Packages",
+ implied_group='stock.group_tracking_lot')
+ group_stock_tracking_owner = fields.Boolean("Consignment",
+ implied_group='stock.group_tracking_owner')
+ group_stock_adv_location = fields.Boolean("Multi-Step Routes",
+ implied_group='stock.group_adv_location',
+ help="Add and customize route operations to process product moves in your warehouse(s): e.g. unload > quality control > stock for incoming products, pick > pack > ship for outgoing products. \n You can also set putaway strategies on warehouse locations in order to send incoming products into specific child locations straight away (e.g. specific bins, racks).")
+ group_warning_stock = fields.Boolean("Warnings for Stock", implied_group='stock.group_warning_stock')
+ group_stock_sign_delivery = fields.Boolean("Signature", implied_group='stock.group_stock_sign_delivery')
+ module_stock_picking_batch = fields.Boolean("Batch Pickings")
+ module_stock_barcode = fields.Boolean("Barcode Scanner")
+ stock_move_email_validation = fields.Boolean(related='company_id.stock_move_email_validation', readonly=False)
+ stock_mail_confirmation_template_id = fields.Many2one(related='company_id.stock_mail_confirmation_template_id', readonly=False)
+ module_stock_sms = fields.Boolean("SMS Confirmation")
+ module_delivery = fields.Boolean("Delivery Methods")
+ module_delivery_dhl = fields.Boolean("DHL USA Connector")
+ module_delivery_fedex = fields.Boolean("FedEx Connector")
+ module_delivery_ups = fields.Boolean("UPS Connector")
+ module_delivery_usps = fields.Boolean("USPS Connector")
+ module_delivery_bpost = fields.Boolean("bpost Connector")
+ module_delivery_easypost = fields.Boolean("Easypost Connector")
+ group_stock_multi_locations = fields.Boolean('Storage Locations', implied_group='stock.group_stock_multi_locations',
+ help="Store products in specific locations of your warehouse (e.g. bins, racks) and to track inventory accordingly.")
+
+ @api.onchange('group_stock_multi_locations')
+ def _onchange_group_stock_multi_locations(self):
+ if not self.group_stock_multi_locations:
+ self.group_stock_adv_location = False
+
+ @api.onchange('group_stock_production_lot')
+ def _onchange_group_stock_production_lot(self):
+ if not self.group_stock_production_lot:
+ self.group_lot_on_delivery_slip = False
+
+ @api.onchange('group_stock_adv_location')
+ def onchange_adv_location(self):
+ if self.group_stock_adv_location and not self.group_stock_multi_locations:
+ self.group_stock_multi_locations = True
+
+ def set_values(self):
+ if self.module_procurement_jit == '0':
+ self.env['ir.config_parameter'].sudo().set_param('stock.picking_no_auto_reserve', True)
+ else:
+ self.env['ir.config_parameter'].sudo().set_param('stock.picking_no_auto_reserve', False)
+ warehouse_grp = self.env.ref('stock.group_stock_multi_warehouses')
+ location_grp = self.env.ref('stock.group_stock_multi_locations')
+ base_user = self.env.ref('base.group_user')
+ if not self.group_stock_multi_locations and location_grp in base_user.implied_ids and warehouse_grp in base_user.implied_ids:
+ raise UserError(_("You can't desactivate the multi-location if you have more than once warehouse by company"))
+
+ previous_group = self.default_get(['group_stock_multi_locations', 'group_stock_production_lot', 'group_stock_tracking_lot'])
+ res = super(ResConfigSettings, self).set_values()
+
+ if not self.user_has_groups('stock.group_stock_manager'):
+ return
+
+ """ If we disable multiple locations, we can deactivate the internal
+ operation types of the warehouses, so they won't appear in the dashboard.
+ Otherwise, activate them.
+ """
+ warehouse_obj = self.env['stock.warehouse']
+ if self.group_stock_multi_locations and not previous_group.get('group_stock_multi_locations'):
+ # override active_test that is false in set_values
+ warehouse_obj.with_context(active_test=True).search([]).mapped('int_type_id').write({'active': True})
+ elif not self.group_stock_multi_locations and previous_group.get('group_stock_multi_locations'):
+ warehouse_obj.search([
+ ('reception_steps', '=', 'one_step'),
+ ('delivery_steps', '=', 'ship_only')]
+ ).mapped('int_type_id').write({'active': False})
+
+ if any(self[group] and not prev_value for group, prev_value in previous_group.items()):
+ picking_types = self.env['stock.picking.type'].with_context(active_test=False).search([
+ ('code', '!=', 'incoming'),
+ ('show_operations', '=', False)
+ ])
+ picking_types.sudo().write({'show_operations': True})
+ return res
diff --git a/addons/stock/models/res_partner.py b/addons/stock/models/res_partner.py
new file mode 100644
index 00000000..78244d20
--- /dev/null
+++ b/addons/stock/models/res_partner.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+from odoo.addons.base.models.res_partner import WARNING_HELP, WARNING_MESSAGE
+
+
+class Partner(models.Model):
+ _inherit = 'res.partner'
+ _check_company_auto = True
+
+ property_stock_customer = fields.Many2one(
+ 'stock.location', string="Customer Location", company_dependent=True, check_company=True,
+ domain="['|', ('company_id', '=', False), ('company_id', '=', allowed_company_ids[0])]",
+ help="The stock location used as destination when sending goods to this contact.")
+ property_stock_supplier = fields.Many2one(
+ 'stock.location', string="Vendor Location", company_dependent=True, check_company=True,
+ domain="['|', ('company_id', '=', False), ('company_id', '=', allowed_company_ids[0])]",
+ help="The stock location used as source when receiving goods from this contact.")
+ picking_warn = fields.Selection(WARNING_MESSAGE, 'Stock Picking', help=WARNING_HELP, default='no-message')
+ picking_warn_msg = fields.Text('Message for Stock Picking')
diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py
new file mode 100644
index 00000000..6d247bc3
--- /dev/null
+++ b/addons/stock/models/stock_inventory.py
@@ -0,0 +1,591 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import _, api, fields, models
+from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
+from odoo.exceptions import UserError, ValidationError
+from odoo.osv import expression
+from odoo.tools import float_compare, float_is_zero
+
+
+class Inventory(models.Model):
+ _name = "stock.inventory"
+ _description = "Inventory"
+ _order = "date desc, id desc"
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+
+ name = fields.Char(
+ 'Inventory Reference', default="Inventory",
+ readonly=True, required=True,
+ states={'draft': [('readonly', False)]})
+ date = fields.Datetime(
+ 'Inventory Date',
+ readonly=True, required=True,
+ default=fields.Datetime.now,
+ help="If the inventory adjustment is not validated, date at which the theoritical quantities have been checked.\n"
+ "If the inventory adjustment is validated, date at which the inventory adjustment has been validated.")
+ line_ids = fields.One2many(
+ 'stock.inventory.line', 'inventory_id', string='Inventories',
+ copy=False, readonly=False,
+ states={'done': [('readonly', True)]})
+ move_ids = fields.One2many(
+ 'stock.move', 'inventory_id', string='Created Moves',
+ states={'done': [('readonly', True)]})
+ state = fields.Selection(string='Status', selection=[
+ ('draft', 'Draft'),
+ ('cancel', 'Cancelled'),
+ ('confirm', 'In Progress'),
+ ('done', 'Validated')],
+ copy=False, index=True, readonly=True, tracking=True,
+ default='draft')
+ company_id = fields.Many2one(
+ 'res.company', 'Company',
+ readonly=True, index=True, required=True,
+ states={'draft': [('readonly', False)]},
+ default=lambda self: self.env.company)
+ location_ids = fields.Many2many(
+ 'stock.location', string='Locations',
+ readonly=True, check_company=True,
+ states={'draft': [('readonly', False)]},
+ domain="[('company_id', '=', company_id), ('usage', 'in', ['internal', 'transit'])]")
+ product_ids = fields.Many2many(
+ 'product.product', string='Products', check_company=True,
+ domain="[('type', '=', 'product'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", readonly=True,
+ states={'draft': [('readonly', False)]},
+ help="Specify Products to focus your inventory on particular Products.")
+ start_empty = fields.Boolean('Empty Inventory',
+ help="Allows to start with an empty inventory.")
+ prefill_counted_quantity = fields.Selection(string='Counted Quantities',
+ help="Allows to start with a pre-filled counted quantity for each lines or "
+ "with all counted quantities set to zero.", default='counted',
+ selection=[('counted', 'Default to stock on hand'), ('zero', 'Default to zero')])
+ exhausted = fields.Boolean(
+ 'Include Exhausted Products', readonly=True,
+ states={'draft': [('readonly', False)]},
+ help="Include also products with quantity of 0")
+
+ @api.onchange('company_id')
+ def _onchange_company_id(self):
+ # If the multilocation group is not active, default the location to the one of the main
+ # warehouse.
+ if not self.user_has_groups('stock.group_stock_multi_locations'):
+ warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.company_id.id)], limit=1)
+ if warehouse:
+ self.location_ids = warehouse.lot_stock_id
+
+ def copy_data(self, default=None):
+ name = _("%s (copy)") % (self.name)
+ default = dict(default or {}, name=name)
+ return super(Inventory, self).copy_data(default)
+
+ def unlink(self):
+ for inventory in self:
+ if (inventory.state not in ('draft', 'cancel')
+ and not self.env.context.get(MODULE_UNINSTALL_FLAG, False)):
+ raise UserError(_('You can only delete a draft inventory adjustment. If the inventory adjustment is not done, you can cancel it.'))
+ return super(Inventory, self).unlink()
+
+ def action_validate(self):
+ if not self.exists():
+ return
+ self.ensure_one()
+ if not self.user_has_groups('stock.group_stock_manager'):
+ raise UserError(_("Only a stock manager can validate an inventory adjustment."))
+ if self.state != 'confirm':
+ raise UserError(_(
+ "You can't validate the inventory '%s', maybe this inventory "
+ "has been already validated or isn't ready.", self.name))
+ inventory_lines = self.line_ids.filtered(lambda l: l.product_id.tracking in ['lot', 'serial'] and not l.prod_lot_id and l.theoretical_qty != l.product_qty)
+ lines = self.line_ids.filtered(lambda l: float_compare(l.product_qty, 1, precision_rounding=l.product_uom_id.rounding) > 0 and l.product_id.tracking == 'serial' and l.prod_lot_id)
+ if inventory_lines and not lines:
+ wiz_lines = [(0, 0, {'product_id': product.id, 'tracking': product.tracking}) for product in inventory_lines.mapped('product_id')]
+ wiz = self.env['stock.track.confirmation'].create({'inventory_id': self.id, 'tracking_line_ids': wiz_lines})
+ return {
+ 'name': _('Tracked Products in Inventory Adjustment'),
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'views': [(False, 'form')],
+ 'res_model': 'stock.track.confirmation',
+ 'target': 'new',
+ 'res_id': wiz.id,
+ }
+ self._action_done()
+ self.line_ids._check_company()
+ self._check_company()
+ return True
+
+ def _action_done(self):
+ negative = next((line for line in self.mapped('line_ids') if line.product_qty < 0 and line.product_qty != line.theoretical_qty), False)
+ if negative:
+ raise UserError(_(
+ 'You cannot set a negative product quantity in an inventory line:\n\t%s - qty: %s',
+ negative.product_id.display_name,
+ negative.product_qty
+ ))
+ self.action_check()
+ self.write({'state': 'done', 'date': fields.Datetime.now()})
+ self.post_inventory()
+ return True
+
+ def post_inventory(self):
+ # The inventory is posted as a single step which means quants cannot be moved from an internal location to another using an inventory
+ # as they will be moved to inventory loss, and other quants will be created to the encoded quant location. This is a normal behavior
+ # as quants cannot be reuse from inventory location (users can still manually move the products before/after the inventory if they want).
+ self.mapped('move_ids').filtered(lambda move: move.state != 'done')._action_done()
+ return True
+
+ def action_check(self):
+ """ Checks the inventory and computes the stock move to do """
+ # tde todo: clean after _generate_moves
+ for inventory in self.filtered(lambda x: x.state not in ('done','cancel')):
+ # first remove the existing stock moves linked to this inventory
+ inventory.with_context(prefetch_fields=False).mapped('move_ids').unlink()
+ inventory.line_ids._generate_moves()
+
+ def action_cancel_draft(self):
+ self.mapped('move_ids')._action_cancel()
+ self.line_ids.unlink()
+ self.write({'state': 'draft'})
+
+ def action_start(self):
+ self.ensure_one()
+ self._action_start()
+ self._check_company()
+ return self.action_open_inventory_lines()
+
+ def _action_start(self):
+ """ Confirms the Inventory Adjustment and generates its inventory lines
+ if its state is draft and don't have already inventory lines (can happen
+ with demo data or tests).
+ """
+ for inventory in self:
+ if inventory.state != 'draft':
+ continue
+ vals = {
+ 'state': 'confirm',
+ 'date': fields.Datetime.now()
+ }
+ if not inventory.line_ids and not inventory.start_empty:
+ self.env['stock.inventory.line'].create(inventory._get_inventory_lines_values())
+ inventory.write(vals)
+
+ def action_open_inventory_lines(self):
+ self.ensure_one()
+ action = {
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'tree',
+ 'name': _('Inventory Lines'),
+ 'res_model': 'stock.inventory.line',
+ }
+ context = {
+ 'default_is_editable': True,
+ 'default_inventory_id': self.id,
+ 'default_company_id': self.company_id.id,
+ }
+ # Define domains and context
+ domain = [
+ ('inventory_id', '=', self.id),
+ ('location_id.usage', 'in', ['internal', 'transit'])
+ ]
+ if self.location_ids:
+ context['default_location_id'] = self.location_ids[0].id
+ if len(self.location_ids) == 1:
+ if not self.location_ids[0].child_ids:
+ context['readonly_location_id'] = True
+
+ if self.product_ids:
+ # no_create on product_id field
+ action['view_id'] = self.env.ref('stock.stock_inventory_line_tree_no_product_create').id
+ if len(self.product_ids) == 1:
+ context['default_product_id'] = self.product_ids[0].id
+ else:
+ # no product_ids => we're allowed to create new products in tree
+ action['view_id'] = self.env.ref('stock.stock_inventory_line_tree').id
+
+ action['context'] = context
+ action['domain'] = domain
+ return action
+
+ def action_view_related_move_lines(self):
+ self.ensure_one()
+ domain = [('move_id', 'in', self.move_ids.ids)]
+ action = {
+ 'name': _('Product Moves'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'stock.move.line',
+ 'view_type': 'list',
+ 'view_mode': 'list,form',
+ 'domain': domain,
+ }
+ return action
+
+ def action_print(self):
+ return self.env.ref('stock.action_report_inventory').report_action(self)
+
+ def _get_quantities(self):
+ """Return quantities group by product_id, location_id, lot_id, package_id and owner_id
+
+ :return: a dict with keys as tuple of group by and quantity as value
+ :rtype: dict
+ """
+ self.ensure_one()
+ if self.location_ids:
+ domain_loc = [('id', 'child_of', self.location_ids.ids)]
+ else:
+ domain_loc = [('company_id', '=', self.company_id.id), ('usage', 'in', ['internal', 'transit'])]
+ locations_ids = [l['id'] for l in self.env['stock.location'].search_read(domain_loc, ['id'])]
+
+ domain = [('company_id', '=', self.company_id.id),
+ ('quantity', '!=', '0'),
+ ('location_id', 'in', locations_ids)]
+ if self.prefill_counted_quantity == 'zero':
+ domain.append(('product_id.active', '=', True))
+
+ if self.product_ids:
+ domain = expression.AND([domain, [('product_id', 'in', self.product_ids.ids)]])
+
+ fields = ['product_id', 'location_id', 'lot_id', 'package_id', 'owner_id', 'quantity:sum']
+ group_by = ['product_id', 'location_id', 'lot_id', 'package_id', 'owner_id']
+
+ quants = self.env['stock.quant'].read_group(domain, fields, group_by, lazy=False)
+ return {(
+ quant['product_id'] and quant['product_id'][0] or False,
+ quant['location_id'] and quant['location_id'][0] or False,
+ quant['lot_id'] and quant['lot_id'][0] or False,
+ quant['package_id'] and quant['package_id'][0] or False,
+ quant['owner_id'] and quant['owner_id'][0] or False):
+ quant['quantity'] for quant in quants
+ }
+
+ def _get_exhausted_inventory_lines_vals(self, non_exhausted_set):
+ """Return the values of the inventory lines to create if the user
+ wants to include exhausted products. Exhausted products are products
+ without quantities or quantity equal to 0.
+
+ :param non_exhausted_set: set of tuple (product_id, location_id) of non exhausted product-location
+ :return: a list containing the `stock.inventory.line` values to create
+ :rtype: list
+ """
+ self.ensure_one()
+ if self.product_ids:
+ product_ids = self.product_ids.ids
+ else:
+ product_ids = self.env['product.product'].search_read([
+ '|', ('company_id', '=', self.company_id.id), ('company_id', '=', False),
+ ('type', '=', 'product'),
+ ('active', '=', True)], ['id'])
+ product_ids = [p['id'] for p in product_ids]
+
+ if self.location_ids:
+ location_ids = self.location_ids.ids
+ else:
+ location_ids = self.env['stock.warehouse'].search([('company_id', '=', self.company_id.id)]).lot_stock_id.ids
+
+ vals = []
+ for product_id in product_ids:
+ for location_id in location_ids:
+ if ((product_id, location_id) not in non_exhausted_set):
+ vals.append({
+ 'inventory_id': self.id,
+ 'product_id': product_id,
+ 'location_id': location_id,
+ 'theoretical_qty': 0
+ })
+ return vals
+
+ def _get_inventory_lines_values(self):
+ """Return the values of the inventory lines to create for this inventory.
+
+ :return: a list containing the `stock.inventory.line` values to create
+ :rtype: list
+ """
+ self.ensure_one()
+ quants_groups = self._get_quantities()
+ vals = []
+ for (product_id, location_id, lot_id, package_id, owner_id), quantity in quants_groups.items():
+ line_values = {
+ 'inventory_id': self.id,
+ 'product_qty': 0 if self.prefill_counted_quantity == "zero" else quantity,
+ 'theoretical_qty': quantity,
+ 'prod_lot_id': lot_id,
+ 'partner_id': owner_id,
+ 'product_id': product_id,
+ 'location_id': location_id,
+ 'package_id': package_id
+ }
+ line_values['product_uom_id'] = self.env['product.product'].browse(product_id).uom_id.id
+ vals.append(line_values)
+ if self.exhausted:
+ vals += self._get_exhausted_inventory_lines_vals({(l['product_id'], l['location_id']) for l in vals})
+ return vals
+
+
+class InventoryLine(models.Model):
+ _name = "stock.inventory.line"
+ _description = "Inventory Line"
+ _order = "product_id, inventory_id, location_id, prod_lot_id"
+
+ @api.model
+ def _domain_location_id(self):
+ if self.env.context.get('active_model') == 'stock.inventory':
+ inventory = self.env['stock.inventory'].browse(self.env.context.get('active_id'))
+ if inventory.exists() and inventory.location_ids:
+ return "[('company_id', '=', company_id), ('usage', 'in', ['internal', 'transit']), ('id', 'child_of', %s)]" % inventory.location_ids.ids
+ return "[('company_id', '=', company_id), ('usage', 'in', ['internal', 'transit'])]"
+
+ @api.model
+ def _domain_product_id(self):
+ if self.env.context.get('active_model') == 'stock.inventory':
+ inventory = self.env['stock.inventory'].browse(self.env.context.get('active_id'))
+ if inventory.exists() and len(inventory.product_ids) > 1:
+ return "[('type', '=', 'product'), '|', ('company_id', '=', False), ('company_id', '=', company_id), ('id', 'in', %s)]" % inventory.product_ids.ids
+ return "[('type', '=', 'product'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]"
+
+ is_editable = fields.Boolean(help="Technical field to restrict editing.")
+ inventory_id = fields.Many2one(
+ 'stock.inventory', 'Inventory', check_company=True,
+ index=True, ondelete='cascade')
+ partner_id = fields.Many2one('res.partner', 'Owner', check_company=True)
+ product_id = fields.Many2one(
+ 'product.product', 'Product', check_company=True,
+ domain=lambda self: self._domain_product_id(),
+ index=True, required=True)
+ product_uom_id = fields.Many2one(
+ 'uom.uom', 'Product Unit of Measure',
+ required=True, readonly=True)
+ product_qty = fields.Float(
+ 'Counted Quantity',
+ readonly=True, states={'confirm': [('readonly', False)]},
+ digits='Product Unit of Measure', default=0)
+ categ_id = fields.Many2one(related='product_id.categ_id', store=True)
+ location_id = fields.Many2one(
+ 'stock.location', 'Location', check_company=True,
+ domain=lambda self: self._domain_location_id(),
+ index=True, required=True)
+ package_id = fields.Many2one(
+ 'stock.quant.package', 'Pack', index=True, check_company=True,
+ domain="[('location_id', '=', location_id)]",
+ )
+ prod_lot_id = fields.Many2one(
+ 'stock.production.lot', 'Lot/Serial Number', check_company=True,
+ domain="[('product_id','=',product_id), ('company_id', '=', company_id)]")
+ company_id = fields.Many2one(
+ 'res.company', 'Company', related='inventory_id.company_id',
+ index=True, readonly=True, store=True)
+ state = fields.Selection(string='Status', related='inventory_id.state')
+ theoretical_qty = fields.Float(
+ 'Theoretical Quantity',
+ digits='Product Unit of Measure', readonly=True)
+ difference_qty = fields.Float('Difference', compute='_compute_difference',
+ help="Indicates the gap between the product's theoretical quantity and its newest quantity.",
+ readonly=True, digits='Product Unit of Measure', search="_search_difference_qty")
+ inventory_date = fields.Datetime('Inventory Date', readonly=True,
+ default=fields.Datetime.now,
+ help="Last date at which the On Hand Quantity has been computed.")
+ outdated = fields.Boolean(string='Quantity outdated',
+ compute='_compute_outdated', search='_search_outdated')
+ product_tracking = fields.Selection(string='Tracking', related='product_id.tracking', readonly=True)
+
+ @api.depends('product_qty', 'theoretical_qty')
+ def _compute_difference(self):
+ for line in self:
+ line.difference_qty = line.product_qty - line.theoretical_qty
+
+ @api.depends('inventory_date', 'product_id.stock_move_ids', 'theoretical_qty', 'product_uom_id.rounding')
+ def _compute_outdated(self):
+ quants_by_inventory = {inventory: inventory._get_quantities() for inventory in self.inventory_id}
+ for line in self:
+ quants = quants_by_inventory[line.inventory_id]
+ if line.state == 'done' or not line.id:
+ line.outdated = False
+ continue
+ qty = quants.get((
+ line.product_id.id,
+ line.location_id.id,
+ line.prod_lot_id.id,
+ line.package_id.id,
+ line.partner_id.id), 0
+ )
+ if float_compare(qty, line.theoretical_qty, precision_rounding=line.product_uom_id.rounding) != 0:
+ line.outdated = True
+ else:
+ line.outdated = False
+
+ @api.onchange('product_id', 'location_id', 'product_uom_id', 'prod_lot_id', 'partner_id', 'package_id')
+ def _onchange_quantity_context(self):
+ if self.product_id:
+ self.product_uom_id = self.product_id.uom_id
+ if self.product_id and self.location_id and self.product_id.uom_id.category_id == self.product_uom_id.category_id: # TDE FIXME: last part added because crash
+ theoretical_qty = self.product_id.get_theoretical_quantity(
+ self.product_id.id,
+ self.location_id.id,
+ lot_id=self.prod_lot_id.id,
+ package_id=self.package_id.id,
+ owner_id=self.partner_id.id,
+ to_uom=self.product_uom_id.id,
+ )
+ else:
+ theoretical_qty = 0
+ # Sanity check on the lot.
+ if self.prod_lot_id:
+ if self.product_id.tracking == 'none' or self.product_id != self.prod_lot_id.product_id:
+ self.prod_lot_id = False
+
+ if self.prod_lot_id and self.product_id.tracking == 'serial':
+ # We force `product_qty` to 1 for SN tracked product because it's
+ # the only relevant value aside 0 for this kind of product.
+ self.product_qty = 1
+ elif self.product_id and float_compare(self.product_qty, self.theoretical_qty, precision_rounding=self.product_uom_id.rounding) == 0:
+ # We update `product_qty` only if it equals to `theoretical_qty` to
+ # avoid to reset quantity when user manually set it.
+ self.product_qty = theoretical_qty
+ self.theoretical_qty = theoretical_qty
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """ Override to handle the case we create inventory line without
+ `theoretical_qty` because this field is usually computed, but in some
+ case (typicaly in tests), we create inventory line without trigger the
+ onchange, so in this case, we set `theoretical_qty` depending of the
+ product's theoretical quantity.
+ Handles the same problem with `product_uom_id` as this field is normally
+ set in an onchange of `product_id`.
+ Finally, this override checks we don't try to create a duplicated line.
+ """
+ for values in vals_list:
+ if 'theoretical_qty' not in values:
+ theoretical_qty = self.env['product.product'].get_theoretical_quantity(
+ values['product_id'],
+ values['location_id'],
+ lot_id=values.get('prod_lot_id'),
+ package_id=values.get('package_id'),
+ owner_id=values.get('partner_id'),
+ to_uom=values.get('product_uom_id'),
+ )
+ values['theoretical_qty'] = theoretical_qty
+ if 'product_id' in values and 'product_uom_id' not in values:
+ values['product_uom_id'] = self.env['product.product'].browse(values['product_id']).uom_id.id
+ res = super(InventoryLine, self).create(vals_list)
+ res._check_no_duplicate_line()
+ return res
+
+ def write(self, vals):
+ res = super(InventoryLine, self).write(vals)
+ self._check_no_duplicate_line()
+ return res
+
+ def _check_no_duplicate_line(self):
+ for line in self:
+ domain = [
+ ('id', '!=', line.id),
+ ('product_id', '=', line.product_id.id),
+ ('location_id', '=', line.location_id.id),
+ ('partner_id', '=', line.partner_id.id),
+ ('package_id', '=', line.package_id.id),
+ ('prod_lot_id', '=', line.prod_lot_id.id),
+ ('inventory_id', '=', line.inventory_id.id)]
+ existings = self.search_count(domain)
+ if existings:
+ raise UserError(_("There is already one inventory adjustment line for this product,"
+ " you should rather modify this one instead of creating a new one."))
+
+ @api.constrains('product_id')
+ def _check_product_id(self):
+ """ As no quants are created for consumable products, it should not be possible do adjust
+ their quantity.
+ """
+ for line in self:
+ if line.product_id.type != 'product':
+ raise ValidationError(_("You can only adjust storable products.") + '\n\n%s -> %s' % (line.product_id.display_name, line.product_id.type))
+
+ def _get_move_values(self, qty, location_id, location_dest_id, out):
+ self.ensure_one()
+ return {
+ 'name': _('INV:') + (self.inventory_id.name or ''),
+ 'product_id': self.product_id.id,
+ 'product_uom': self.product_uom_id.id,
+ 'product_uom_qty': qty,
+ 'date': self.inventory_id.date,
+ 'company_id': self.inventory_id.company_id.id,
+ 'inventory_id': self.inventory_id.id,
+ 'state': 'confirmed',
+ 'restrict_partner_id': self.partner_id.id,
+ 'location_id': location_id,
+ 'location_dest_id': location_dest_id,
+ 'move_line_ids': [(0, 0, {
+ 'product_id': self.product_id.id,
+ 'lot_id': self.prod_lot_id.id,
+ 'product_uom_qty': 0, # bypass reservation here
+ 'product_uom_id': self.product_uom_id.id,
+ 'qty_done': qty,
+ 'package_id': out and self.package_id.id or False,
+ 'result_package_id': (not out) and self.package_id.id or False,
+ 'location_id': location_id,
+ 'location_dest_id': location_dest_id,
+ 'owner_id': self.partner_id.id,
+ })]
+ }
+
+ def _get_virtual_location(self):
+ return self.product_id.with_company(self.company_id).property_stock_inventory
+
+ def _generate_moves(self):
+ vals_list = []
+ for line in self:
+ virtual_location = line._get_virtual_location()
+ rounding = line.product_id.uom_id.rounding
+ if float_is_zero(line.difference_qty, precision_rounding=rounding):
+ continue
+ if line.difference_qty > 0: # found more than expected
+ vals = line._get_move_values(line.difference_qty, virtual_location.id, line.location_id.id, False)
+ else:
+ vals = line._get_move_values(abs(line.difference_qty), line.location_id.id, virtual_location.id, True)
+ vals_list.append(vals)
+ return self.env['stock.move'].create(vals_list)
+
+ def action_refresh_quantity(self):
+ filtered_lines = self.filtered(lambda l: l.state != 'done')
+ for line in filtered_lines:
+ if line.outdated:
+ quants = self.env['stock.quant']._gather(line.product_id, line.location_id, lot_id=line.prod_lot_id, package_id=line.package_id, owner_id=line.partner_id, strict=True)
+ if quants.exists():
+ quantity = sum(quants.mapped('quantity'))
+ if line.theoretical_qty != quantity:
+ line.theoretical_qty = quantity
+ else:
+ line.theoretical_qty = 0
+ line.inventory_date = fields.Datetime.now()
+
+ def action_reset_product_qty(self):
+ """ Write `product_qty` to zero on the selected records. """
+ impacted_lines = self.env['stock.inventory.line']
+ for line in self:
+ if line.state == 'done':
+ continue
+ impacted_lines |= line
+ impacted_lines.write({'product_qty': 0})
+
+ def _search_difference_qty(self, operator, value):
+ if operator == '=':
+ result = True
+ elif operator == '!=':
+ result = False
+ else:
+ raise NotImplementedError()
+ if not self.env.context.get('default_inventory_id'):
+ raise NotImplementedError(_('Unsupported search on %s outside of an Inventory Adjustment', 'difference_qty'))
+ lines = self.search([('inventory_id', '=', self.env.context.get('default_inventory_id'))])
+ line_ids = lines.filtered(lambda line: float_is_zero(line.difference_qty, line.product_id.uom_id.rounding) == result).ids
+ return [('id', 'in', line_ids)]
+
+ def _search_outdated(self, operator, value):
+ if operator != '=':
+ if operator == '!=' and isinstance(value, bool):
+ value = not value
+ else:
+ raise NotImplementedError()
+ if not self.env.context.get('default_inventory_id'):
+ raise NotImplementedError(_('Unsupported search on %s outside of an Inventory Adjustment', 'outdated'))
+ lines = self.search([('inventory_id', '=', self.env.context.get('default_inventory_id'))])
+ line_ids = lines.filtered(lambda line: line.outdated == value).ids
+ return [('id', 'in', line_ids)]
diff --git a/addons/stock/models/stock_location.py b/addons/stock/models/stock_location.py
new file mode 100644
index 00000000..b892d3df
--- /dev/null
+++ b/addons/stock/models/stock_location.py
@@ -0,0 +1,214 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+from odoo.osv import expression
+
+
+class Location(models.Model):
+ _name = "stock.location"
+ _description = "Inventory Locations"
+ _parent_name = "location_id"
+ _parent_store = True
+ _order = 'complete_name'
+ _rec_name = 'complete_name'
+ _check_company_auto = True
+
+ @api.model
+ def default_get(self, fields):
+ res = super(Location, self).default_get(fields)
+ if 'barcode' in fields and 'barcode' not in res and res.get('complete_name'):
+ res['barcode'] = res['complete_name']
+ return res
+
+ name = fields.Char('Location Name', required=True)
+ complete_name = fields.Char("Full Location Name", compute='_compute_complete_name', store=True)
+ active = fields.Boolean('Active', default=True, help="By unchecking the active field, you may hide a location without deleting it.")
+ usage = fields.Selection([
+ ('supplier', 'Vendor Location'),
+ ('view', 'View'),
+ ('internal', 'Internal Location'),
+ ('customer', 'Customer Location'),
+ ('inventory', 'Inventory Loss'),
+ ('production', 'Production'),
+ ('transit', 'Transit Location')], string='Location Type',
+ default='internal', index=True, required=True,
+ help="* Vendor Location: Virtual location representing the source location for products coming from your vendors"
+ "\n* View: Virtual location used to create a hierarchical structures for your warehouse, aggregating its child locations ; can't directly contain products"
+ "\n* Internal Location: Physical locations inside your own warehouses,"
+ "\n* Customer Location: Virtual location representing the destination location for products sent to your customers"
+ "\n* Inventory Loss: Virtual location serving as counterpart for inventory operations used to correct stock levels (Physical inventories)"
+ "\n* Production: Virtual counterpart location for production operations: this location consumes the components and produces finished products"
+ "\n* Transit Location: Counterpart location that should be used in inter-company or inter-warehouses operations")
+ location_id = fields.Many2one(
+ 'stock.location', 'Parent Location', index=True, ondelete='cascade', check_company=True,
+ help="The parent location that includes this location. Example : The 'Dispatch Zone' is the 'Gate 1' parent location.")
+ child_ids = fields.One2many('stock.location', 'location_id', 'Contains')
+ comment = fields.Text('Additional Information')
+ posx = fields.Integer('Corridor (X)', default=0, help="Optional localization details, for information purpose only")
+ posy = fields.Integer('Shelves (Y)', default=0, help="Optional localization details, for information purpose only")
+ posz = fields.Integer('Height (Z)', default=0, help="Optional localization details, for information purpose only")
+ parent_path = fields.Char(index=True)
+ company_id = fields.Many2one(
+ 'res.company', 'Company',
+ default=lambda self: self.env.company, index=True,
+ help='Let this field empty if this location is shared between companies')
+ scrap_location = fields.Boolean('Is a Scrap Location?', default=False, help='Check this box to allow using this location to put scrapped/damaged goods.')
+ return_location = fields.Boolean('Is a Return Location?', help='Check this box to allow using this location as a return location.')
+ removal_strategy_id = fields.Many2one('product.removal', 'Removal Strategy', help="Defines the default method used for suggesting the exact location (shelf) where to take the products from, which lot etc. for this location. This method can be enforced at the product category level, and a fallback is made on the parent locations if none is set here.")
+ putaway_rule_ids = fields.One2many('stock.putaway.rule', 'location_in_id', 'Putaway Rules')
+ barcode = fields.Char('Barcode', copy=False)
+ quant_ids = fields.One2many('stock.quant', 'location_id')
+
+ _sql_constraints = [('barcode_company_uniq', 'unique (barcode,company_id)', 'The barcode for a location must be unique per company !')]
+
+ @api.depends('name', 'location_id.complete_name')
+ def _compute_complete_name(self):
+ for location in self:
+ if location.location_id and location.usage != 'view':
+ location.complete_name = '%s/%s' % (location.location_id.complete_name, location.name)
+ else:
+ location.complete_name = location.name
+
+ @api.onchange('usage')
+ def _onchange_usage(self):
+ if self.usage not in ('internal', 'inventory'):
+ self.scrap_location = False
+
+ def write(self, values):
+ if 'company_id' in values:
+ for location in self:
+ if location.company_id.id != values['company_id']:
+ raise UserError(_("Changing the company of this record is forbidden at this point, you should rather archive it and create a new one."))
+ if 'usage' in values and values['usage'] == 'view':
+ if self.mapped('quant_ids'):
+ raise UserError(_("This location's usage cannot be changed to view as it contains products."))
+ if 'usage' in values or 'scrap_location' in values:
+ modified_locations = self.filtered(
+ lambda l: any(l[f] != values[f] if f in values else False
+ for f in {'usage', 'scrap_location'}))
+ reserved_quantities = self.env['stock.move.line'].search_count([
+ ('location_id', 'in', modified_locations.ids),
+ ('product_qty', '>', 0),
+ ])
+ if reserved_quantities:
+ raise UserError(_(
+ "You cannot change the location type or its use as a scrap"
+ " location as there are products reserved in this location."
+ " Please unreserve the products first."
+ ))
+ if 'active' in values:
+ if values['active'] == False:
+ for location in self:
+ warehouses = self.env['stock.warehouse'].search([('active', '=', True), '|', ('lot_stock_id', '=', location.id), ('view_location_id', '=', location.id)])
+ if warehouses:
+ raise UserError(_("You cannot archive the location %s as it is"
+ " used by your warehouse %s") % (location.display_name, warehouses[0].display_name))
+
+ if not self.env.context.get('do_not_check_quant'):
+ children_location = self.env['stock.location'].with_context(active_test=False).search([('id', 'child_of', self.ids)])
+ internal_children_locations = children_location.filtered(lambda l: l.usage == 'internal')
+ children_quants = self.env['stock.quant'].search(['&', '|', ('quantity', '!=', 0), ('reserved_quantity', '!=', 0), ('location_id', 'in', internal_children_locations.ids)])
+ if children_quants and values['active'] == False:
+ raise UserError(_('You still have some product in locations %s') %
+ (', '.join(children_quants.mapped('location_id.display_name'))))
+ else:
+ super(Location, children_location - self).with_context(do_not_check_quant=True).write({
+ 'active': values['active'],
+ })
+
+ return super(Location, self).write(values)
+
+ @api.model
+ def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
+ """ search full name and barcode """
+ args = args or []
+ if operator == 'ilike' and not (name or '').strip():
+ domain = []
+ elif operator in expression.NEGATIVE_TERM_OPERATORS:
+ domain = [('barcode', operator, name), ('complete_name', operator, name)]
+ else:
+ domain = ['|', ('barcode', operator, name), ('complete_name', operator, name)]
+ return self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
+
+ def _get_putaway_strategy(self, product):
+ ''' Returns the location where the product has to be put, if any compliant putaway strategy is found. Otherwise returns None.'''
+ current_location = self
+ putaway_location = self.env['stock.location']
+ while current_location and not putaway_location:
+ # Looking for a putaway about the product.
+ putaway_rules = current_location.putaway_rule_ids.filtered(lambda x: x.product_id == product)
+ if putaway_rules:
+ putaway_location = putaway_rules[0].location_out_id
+ # If not product putaway found, we're looking with category so.
+ else:
+ categ = product.categ_id
+ while categ:
+ putaway_rules = current_location.putaway_rule_ids.filtered(lambda x: x.category_id == categ)
+ if putaway_rules:
+ putaway_location = putaway_rules[0].location_out_id
+ break
+ categ = categ.parent_id
+ current_location = current_location.location_id
+ return putaway_location
+
+ @api.returns('stock.warehouse', lambda value: value.id)
+ def get_warehouse(self):
+ """ Returns warehouse id of warehouse that contains location """
+ domain = [('view_location_id', 'parent_of', self.ids)]
+ return self.env['stock.warehouse'].search(domain, limit=1)
+
+ def should_bypass_reservation(self):
+ self.ensure_one()
+ return self.usage in ('supplier', 'customer', 'inventory', 'production') or self.scrap_location or (self.usage == 'transit' and not self.company_id)
+
+
+class Route(models.Model):
+ _name = 'stock.location.route'
+ _description = "Inventory Routes"
+ _order = 'sequence'
+ _check_company_auto = True
+
+ name = fields.Char('Route', required=True, translate=True)
+ active = fields.Boolean('Active', default=True, help="If the active field is set to False, it will allow you to hide the route without removing it.")
+ sequence = fields.Integer('Sequence', default=0)
+ rule_ids = fields.One2many('stock.rule', 'route_id', 'Rules', copy=True)
+ product_selectable = fields.Boolean('Applicable on Product', default=True, help="When checked, the route will be selectable in the Inventory tab of the Product form.")
+ product_categ_selectable = fields.Boolean('Applicable on Product Category', help="When checked, the route will be selectable on the Product Category.")
+ warehouse_selectable = fields.Boolean('Applicable on Warehouse', help="When a warehouse is selected for this route, this route should be seen as the default route when products pass through this warehouse.")
+ supplied_wh_id = fields.Many2one('stock.warehouse', 'Supplied Warehouse')
+ supplier_wh_id = fields.Many2one('stock.warehouse', 'Supplying Warehouse')
+ company_id = fields.Many2one(
+ 'res.company', 'Company',
+ default=lambda self: self.env.company, index=True,
+ help='Leave this field empty if this route is shared between all companies')
+ product_ids = fields.Many2many(
+ 'product.template', 'stock_route_product', 'route_id', 'product_id',
+ 'Products', copy=False, check_company=True)
+ categ_ids = fields.Many2many('product.category', 'stock_location_route_categ', 'route_id', 'categ_id', 'Product Categories', copy=False)
+ warehouse_domain_ids = fields.One2many('stock.warehouse', compute='_compute_warehouses')
+ warehouse_ids = fields.Many2many(
+ 'stock.warehouse', 'stock_route_warehouse', 'route_id', 'warehouse_id',
+ 'Warehouses', copy=False, domain="[('id', 'in', warehouse_domain_ids)]")
+
+ @api.depends('company_id')
+ def _compute_warehouses(self):
+ for loc in self:
+ domain = [('company_id', '=', loc.company_id.id)] if loc.company_id else []
+ loc.warehouse_domain_ids = self.env['stock.warehouse'].search(domain)
+
+ @api.onchange('company_id')
+ def _onchange_company(self):
+ if self.company_id:
+ self.warehouse_ids = self.warehouse_ids.filtered(lambda w: w.company_id == self.company_id)
+
+ @api.onchange('warehouse_selectable')
+ def _onchange_warehouse_selectable(self):
+ if not self.warehouse_selectable:
+ self.warehouse_ids = [(5, 0, 0)]
+
+ def toggle_active(self):
+ for route in self:
+ route.with_context(active_test=False).rule_ids.filtered(lambda ru: ru.active == route.active).toggle_active()
+ super(Route, self).toggle_active()
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()
diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py
new file mode 100644
index 00000000..b408d861
--- /dev/null
+++ b/addons/stock/models/stock_move_line.py
@@ -0,0 +1,676 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from collections import Counter
+
+from odoo import _, api, fields, tools, models
+from odoo.exceptions import UserError, ValidationError
+from odoo.tools import OrderedSet
+from odoo.tools.float_utils import float_compare, float_is_zero, float_round
+
+
+class StockMoveLine(models.Model):
+ _name = "stock.move.line"
+ _description = "Product Moves (Stock Move Line)"
+ _rec_name = "product_id"
+ _order = "result_package_id desc, id"
+
+ picking_id = fields.Many2one(
+ 'stock.picking', 'Transfer', auto_join=True,
+ check_company=True,
+ index=True,
+ help='The stock operation where the packing has been made')
+ move_id = fields.Many2one(
+ 'stock.move', 'Stock Move',
+ check_company=True,
+ help="Change to a better name", index=True)
+ company_id = fields.Many2one('res.company', string='Company', readonly=True, required=True, index=True)
+ product_id = fields.Many2one('product.product', 'Product', ondelete="cascade", check_company=True, domain="[('type', '!=', 'service'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]")
+ product_uom_id = 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')
+ product_qty = fields.Float(
+ 'Real Reserved Quantity', digits=0, copy=False,
+ compute='_compute_product_qty', inverse='_set_product_qty', store=True)
+ product_uom_qty = fields.Float(
+ 'Reserved', default=0.0, digits='Product Unit of Measure', required=True, copy=False)
+ qty_done = fields.Float('Done', default=0.0, digits='Product Unit of Measure', copy=False)
+ package_id = fields.Many2one(
+ 'stock.quant.package', 'Source Package', ondelete='restrict',
+ check_company=True,
+ domain="[('location_id', '=', location_id)]")
+ package_level_id = fields.Many2one('stock.package_level', 'Package Level', check_company=True)
+ lot_id = fields.Many2one(
+ 'stock.production.lot', 'Lot/Serial Number',
+ domain="[('product_id', '=', product_id), ('company_id', '=', company_id)]", check_company=True)
+ lot_name = fields.Char('Lot/Serial Number Name')
+ result_package_id = fields.Many2one(
+ 'stock.quant.package', 'Destination Package',
+ ondelete='restrict', required=False, check_company=True,
+ domain="['|', '|', ('location_id', '=', False), ('location_id', '=', location_dest_id), ('id', '=', package_id)]",
+ help="If set, the operations are packed into this package")
+ date = fields.Datetime('Date', default=fields.Datetime.now, required=True)
+ owner_id = fields.Many2one(
+ 'res.partner', 'From Owner',
+ check_company=True,
+ help="When validating the transfer, the products will be taken from this owner.")
+ location_id = fields.Many2one('stock.location', 'From', check_company=True, required=True)
+ location_dest_id = fields.Many2one('stock.location', 'To', check_company=True, required=True)
+ lots_visible = fields.Boolean(compute='_compute_lots_visible')
+ picking_code = fields.Selection(related='picking_id.picking_type_id.code', readonly=True)
+ picking_type_use_create_lots = fields.Boolean(related='picking_id.picking_type_id.use_create_lots', readonly=True)
+ picking_type_use_existing_lots = fields.Boolean(related='picking_id.picking_type_id.use_existing_lots', readonly=True)
+ state = fields.Selection(related='move_id.state', store=True, related_sudo=False)
+ is_initial_demand_editable = fields.Boolean(related='move_id.is_initial_demand_editable', readonly=False)
+ is_locked = fields.Boolean(related='move_id.is_locked', default=True, readonly=True)
+ consume_line_ids = fields.Many2many('stock.move.line', 'stock_move_line_consume_rel', 'consume_line_id', 'produce_line_id', help="Technical link to see who consumed what. ")
+ produce_line_ids = fields.Many2many('stock.move.line', 'stock_move_line_consume_rel', 'produce_line_id', 'consume_line_id', help="Technical link to see which line was produced with this. ")
+ reference = fields.Char(related='move_id.reference', store=True, related_sudo=False, readonly=False)
+ tracking = fields.Selection(related='product_id.tracking', readonly=True)
+ origin = fields.Char(related='move_id.origin', string='Source')
+ picking_type_entire_packs = fields.Boolean(related='picking_id.picking_type_id.show_entire_packs', readonly=True)
+ description_picking = fields.Text(string="Description picking")
+
+ @api.depends('picking_id.picking_type_id', 'product_id.tracking')
+ def _compute_lots_visible(self):
+ for line in self:
+ picking = line.picking_id
+ if picking.picking_type_id and line.product_id.tracking != 'none': # TDE FIXME: not sure correctly migrated
+ line.lots_visible = picking.picking_type_id.use_existing_lots or picking.picking_type_id.use_create_lots
+ else:
+ line.lots_visible = line.product_id.tracking != 'none'
+
+ @api.depends('product_id', 'product_uom_id', 'product_uom_qty')
+ def _compute_product_qty(self):
+ for line in self:
+ line.product_qty = line.product_uom_id._compute_quantity(line.product_uom_qty, line.product_id.uom_id, rounding_method='HALF-UP')
+
+ 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.constrains('lot_id', 'product_id')
+ def _check_lot_product(self):
+ for line in self:
+ if line.lot_id and line.product_id != line.lot_id.sudo().product_id:
+ raise ValidationError(_(
+ 'This lot %(lot_name)s is incompatible with this product %(product_name)s',
+ lot_name=line.lot_id.name,
+ product_name=line.product_id.display_name
+ ))
+
+ @api.constrains('product_uom_qty')
+ def _check_reserved_done_quantity(self):
+ for move_line in self:
+ if move_line.state == 'done' and not float_is_zero(move_line.product_uom_qty, precision_digits=self.env['decimal.precision'].precision_get('Product Unit of Measure')):
+ raise ValidationError(_('A done move line should never have a reserved quantity.'))
+
+ @api.constrains('qty_done')
+ def _check_positive_qty_done(self):
+ if any([ml.qty_done < 0 for ml in self]):
+ raise ValidationError(_('You can not enter negative quantities.'))
+
+ @api.onchange('product_id', 'product_uom_id')
+ def _onchange_product_id(self):
+ if self.product_id:
+ if not self.id and self.user_has_groups('stock.group_stock_multi_locations'):
+ self.location_dest_id = self.location_dest_id._get_putaway_strategy(self.product_id) or self.location_dest_id
+ if self.picking_id:
+ product = self.product_id.with_context(lang=self.picking_id.partner_id.lang or self.env.user.lang)
+ self.description_picking = product._get_description(self.picking_id.picking_type_id)
+ self.lots_visible = self.product_id.tracking != 'none'
+ if not self.product_uom_id or self.product_uom_id.category_id != self.product_id.uom_id.category_id:
+ if self.move_id.product_uom:
+ self.product_uom_id = self.move_id.product_uom.id
+ else:
+ self.product_uom_id = self.product_id.uom_id.id
+
+ @api.onchange('lot_name', 'lot_id')
+ def _onchange_serial_number(self):
+ """ When the user is encoding a move line for a tracked product, we apply some logic to
+ help him. This includes:
+ - automatically switch `qty_done` to 1.0
+ - warn if he has already encoded `lot_name` in another move line
+ """
+ res = {}
+ if self.product_id.tracking == 'serial':
+ if not self.qty_done:
+ self.qty_done = 1
+
+ message = None
+ if self.lot_name or self.lot_id:
+ move_lines_to_check = self._get_similar_move_lines() - self
+ if self.lot_name:
+ counter = Counter([line.lot_name for line in move_lines_to_check])
+ if counter.get(self.lot_name) and counter[self.lot_name] > 1:
+ message = _('You cannot use the same serial number twice. Please correct the serial numbers encoded.')
+ elif not self.lot_id:
+ counter = self.env['stock.production.lot'].search_count([
+ ('company_id', '=', self.company_id.id),
+ ('product_id', '=', self.product_id.id),
+ ('name', '=', self.lot_name),
+ ])
+ if counter > 0:
+ message = _('Existing Serial number (%s). Please correct the serial number encoded.') % self.lot_name
+ elif self.lot_id:
+ counter = Counter([line.lot_id.id for line in move_lines_to_check])
+ if counter.get(self.lot_id.id) and counter[self.lot_id.id] > 1:
+ message = _('You cannot use the same serial number twice. Please correct the serial numbers encoded.')
+ if message:
+ res['warning'] = {'title': _('Warning'), 'message': message}
+ return res
+
+ @api.onchange('qty_done', 'product_uom_id')
+ def _onchange_qty_done(self):
+ """ When the user is encoding a move line for a tracked product, we apply some logic to
+ help him. This onchange will warn him if he set `qty_done` to a non-supported value.
+ """
+ res = {}
+ if self.qty_done and self.product_id.tracking == 'serial':
+ qty_done = self.product_uom_id._compute_quantity(self.qty_done, self.product_id.uom_id)
+ if float_compare(qty_done, 1.0, precision_rounding=self.product_id.uom_id.rounding) != 0:
+ message = _('You can only process 1.0 %s of products with unique serial number.', self.product_id.uom_id.name)
+ res['warning'] = {'title': _('Warning'), 'message': message}
+ return res
+
+ def init(self):
+ if not tools.index_exists(self._cr, 'stock_move_line_free_reservation_index'):
+ self._cr.execute("""
+ CREATE INDEX stock_move_line_free_reservation_index
+ ON
+ stock_move_line (id, company_id, product_id, lot_id, location_id, owner_id, package_id)
+ WHERE
+ (state IS NULL OR state NOT IN ('cancel', 'done')) AND product_qty > 0""")
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if vals.get('move_id'):
+ vals['company_id'] = self.env['stock.move'].browse(vals['move_id']).company_id.id
+ elif vals.get('picking_id'):
+ vals['company_id'] = self.env['stock.picking'].browse(vals['picking_id']).company_id.id
+
+ mls = super().create(vals_list)
+
+ def create_move(move_line):
+ new_move = self.env['stock.move'].create({
+ 'name': _('New Move:') + move_line.product_id.display_name,
+ 'product_id': move_line.product_id.id,
+ 'product_uom_qty': 0 if move_line.picking_id and move_line.picking_id.state != 'done' else move_line.qty_done,
+ 'product_uom': move_line.product_uom_id.id,
+ 'description_picking': move_line.description_picking,
+ 'location_id': move_line.picking_id.location_id.id,
+ 'location_dest_id': move_line.picking_id.location_dest_id.id,
+ 'picking_id': move_line.picking_id.id,
+ 'state': move_line.picking_id.state,
+ 'picking_type_id': move_line.picking_id.picking_type_id.id,
+ 'restrict_partner_id': move_line.picking_id.owner_id.id,
+ 'company_id': move_line.picking_id.company_id.id,
+ })
+ move_line.move_id = new_move.id
+
+ # If the move line is directly create on the picking view.
+ # If this picking is already done we should generate an
+ # associated done move.
+ for move_line in mls:
+ if move_line.move_id or not move_line.picking_id:
+ continue
+ if move_line.picking_id.state != 'done':
+ moves = move_line.picking_id.move_lines.filtered(lambda x: x.product_id == move_line.product_id)
+ moves = sorted(moves, key=lambda m: m.quantity_done < m.product_qty, reverse=True)
+ if moves:
+ move_line.move_id = moves[0].id
+ else:
+ create_move(move_line)
+ else:
+ create_move(move_line)
+
+ for ml, vals in zip(mls, vals_list):
+ if ml.move_id and \
+ ml.move_id.picking_id and \
+ ml.move_id.picking_id.immediate_transfer and \
+ ml.move_id.state != 'done' and \
+ 'qty_done' in vals:
+ ml.move_id.product_uom_qty = ml.move_id.quantity_done
+ if ml.state == 'done':
+ if 'qty_done' in vals:
+ ml.move_id.product_uom_qty = ml.move_id.quantity_done
+ if ml.product_id.type == 'product':
+ Quant = self.env['stock.quant']
+ quantity = ml.product_uom_id._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id,rounding_method='HALF-UP')
+ in_date = None
+ available_qty, in_date = Quant._update_available_quantity(ml.product_id, ml.location_id, -quantity, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id)
+ if available_qty < 0 and ml.lot_id:
+ # see if we can compensate the negative quants with some untracked quants
+ untracked_qty = Quant._get_available_quantity(ml.product_id, ml.location_id, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
+ if untracked_qty:
+ taken_from_untracked_qty = min(untracked_qty, abs(quantity))
+ Quant._update_available_quantity(ml.product_id, ml.location_id, -taken_from_untracked_qty, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id)
+ Quant._update_available_quantity(ml.product_id, ml.location_id, taken_from_untracked_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id)
+ Quant._update_available_quantity(ml.product_id, ml.location_dest_id, quantity, lot_id=ml.lot_id, package_id=ml.result_package_id, owner_id=ml.owner_id, in_date=in_date)
+ next_moves = ml.move_id.move_dest_ids.filtered(lambda move: move.state not in ('done', 'cancel'))
+ next_moves._do_unreserve()
+ next_moves._action_assign()
+ return mls
+
+ def write(self, vals):
+ if self.env.context.get('bypass_reservation_update'):
+ return super(StockMoveLine, self).write(vals)
+
+ if 'product_id' in vals and any(vals.get('state', ml.state) != 'draft' and vals['product_id'] != ml.product_id.id for ml in self):
+ raise UserError(_("Changing the product is only allowed in 'Draft' state."))
+
+ moves_to_recompute_state = self.env['stock.move']
+ Quant = self.env['stock.quant']
+ precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+ triggers = [
+ ('location_id', 'stock.location'),
+ ('location_dest_id', 'stock.location'),
+ ('lot_id', 'stock.production.lot'),
+ ('package_id', 'stock.quant.package'),
+ ('result_package_id', 'stock.quant.package'),
+ ('owner_id', 'res.partner')
+ ]
+ updates = {}
+ for key, model in triggers:
+ if key in vals:
+ updates[key] = self.env[model].browse(vals[key])
+
+ if 'result_package_id' in updates:
+ for ml in self.filtered(lambda ml: ml.package_level_id):
+ if updates.get('result_package_id'):
+ ml.package_level_id.package_id = updates.get('result_package_id')
+ else:
+ # TODO: make package levels less of a pain and fix this
+ package_level = ml.package_level_id
+ ml.package_level_id = False
+ package_level.unlink()
+
+ # When we try to write on a reserved move line any fields from `triggers` or directly
+ # `product_uom_qty` (the actual reserved quantity), we need to make sure the associated
+ # quants are correctly updated in order to not make them out of sync (i.e. the sum of the
+ # move lines `product_uom_qty` should always be equal to the sum of `reserved_quantity` on
+ # the quants). If the new charateristics are not available on the quants, we chose to
+ # reserve the maximum possible.
+ if updates or 'product_uom_qty' in vals:
+ for ml in self.filtered(lambda ml: ml.state in ['partially_available', 'assigned'] and ml.product_id.type == 'product'):
+
+ if 'product_uom_qty' in vals:
+ new_product_uom_qty = ml.product_uom_id._compute_quantity(
+ vals['product_uom_qty'], ml.product_id.uom_id, rounding_method='HALF-UP')
+ # Make sure `product_uom_qty` is not negative.
+ if float_compare(new_product_uom_qty, 0, precision_rounding=ml.product_id.uom_id.rounding) < 0:
+ raise UserError(_('Reserving a negative quantity is not allowed.'))
+ else:
+ new_product_uom_qty = ml.product_qty
+
+ # Unreserve the old charateristics of the move line.
+ if not ml._should_bypass_reservation(ml.location_id):
+ Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
+
+ # Reserve the maximum available of the new charateristics of the move line.
+ if not ml._should_bypass_reservation(updates.get('location_id', ml.location_id)):
+ reserved_qty = 0
+ try:
+ q = Quant._update_reserved_quantity(ml.product_id, updates.get('location_id', ml.location_id), new_product_uom_qty, lot_id=updates.get('lot_id', ml.lot_id),
+ package_id=updates.get('package_id', ml.package_id), owner_id=updates.get('owner_id', ml.owner_id), strict=True)
+ reserved_qty = sum([x[1] for x in q])
+ except UserError:
+ pass
+ if reserved_qty != new_product_uom_qty:
+ new_product_uom_qty = ml.product_id.uom_id._compute_quantity(reserved_qty, ml.product_uom_id, rounding_method='HALF-UP')
+ moves_to_recompute_state |= ml.move_id
+ ml.with_context(bypass_reservation_update=True).product_uom_qty = new_product_uom_qty
+
+ # When editing a done move line, the reserved availability of a potential chained move is impacted. Take care of running again `_action_assign` on the concerned moves.
+ if updates or 'qty_done' in vals:
+ next_moves = self.env['stock.move']
+ mls = self.filtered(lambda ml: ml.move_id.state == 'done' and ml.product_id.type == 'product')
+ if not updates: # we can skip those where qty_done is already good up to UoM rounding
+ mls = mls.filtered(lambda ml: not float_is_zero(ml.qty_done - vals['qty_done'], precision_rounding=ml.product_uom_id.rounding))
+ for ml in mls:
+ # undo the original move line
+ qty_done_orig = ml.move_id.product_uom._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP')
+ in_date = Quant._update_available_quantity(ml.product_id, ml.location_dest_id, -qty_done_orig, lot_id=ml.lot_id,
+ package_id=ml.result_package_id, owner_id=ml.owner_id)[1]
+ Quant._update_available_quantity(ml.product_id, ml.location_id, qty_done_orig, lot_id=ml.lot_id,
+ package_id=ml.package_id, owner_id=ml.owner_id, in_date=in_date)
+
+ # move what's been actually done
+ product_id = ml.product_id
+ location_id = updates.get('location_id', ml.location_id)
+ location_dest_id = updates.get('location_dest_id', ml.location_dest_id)
+ qty_done = vals.get('qty_done', ml.qty_done)
+ lot_id = updates.get('lot_id', ml.lot_id)
+ package_id = updates.get('package_id', ml.package_id)
+ result_package_id = updates.get('result_package_id', ml.result_package_id)
+ owner_id = updates.get('owner_id', ml.owner_id)
+ quantity = ml.move_id.product_uom._compute_quantity(qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP')
+ if not ml._should_bypass_reservation(location_id):
+ ml._free_reservation(product_id, location_id, quantity, lot_id=lot_id, package_id=package_id, owner_id=owner_id)
+ if not float_is_zero(quantity, precision_digits=precision):
+ available_qty, in_date = Quant._update_available_quantity(product_id, location_id, -quantity, lot_id=lot_id, package_id=package_id, owner_id=owner_id)
+ if available_qty < 0 and lot_id:
+ # see if we can compensate the negative quants with some untracked quants
+ untracked_qty = Quant._get_available_quantity(product_id, location_id, lot_id=False, package_id=package_id, owner_id=owner_id, strict=True)
+ if untracked_qty:
+ taken_from_untracked_qty = min(untracked_qty, abs(available_qty))
+ Quant._update_available_quantity(product_id, location_id, -taken_from_untracked_qty, lot_id=False, package_id=package_id, owner_id=owner_id)
+ Quant._update_available_quantity(product_id, location_id, taken_from_untracked_qty, lot_id=lot_id, package_id=package_id, owner_id=owner_id)
+ if not ml._should_bypass_reservation(location_id):
+ ml._free_reservation(ml.product_id, location_id, untracked_qty, lot_id=False, package_id=package_id, owner_id=owner_id)
+ Quant._update_available_quantity(product_id, location_dest_id, quantity, lot_id=lot_id, package_id=result_package_id, owner_id=owner_id, in_date=in_date)
+
+ # Unreserve and reserve following move in order to have the real reserved quantity on move_line.
+ next_moves |= ml.move_id.move_dest_ids.filtered(lambda move: move.state not in ('done', 'cancel'))
+
+ # Log a note
+ if ml.picking_id:
+ ml._log_message(ml.picking_id, ml, 'stock.track_move_template', vals)
+
+ res = super(StockMoveLine, self).write(vals)
+
+ # Update scrap object linked to move_lines to the new quantity.
+ if 'qty_done' in vals:
+ for move in self.mapped('move_id'):
+ if move.scrapped:
+ move.scrap_ids.write({'scrap_qty': move.quantity_done})
+
+ # As stock_account values according to a move's `product_uom_qty`, we consider that any
+ # done stock move should have its `quantity_done` equals to its `product_uom_qty`, and
+ # this is what move's `action_done` will do. So, we replicate the behavior here.
+ if updates or 'qty_done' in vals:
+ moves = self.filtered(lambda ml: ml.move_id.state == 'done').mapped('move_id')
+ moves |= self.filtered(lambda ml: ml.move_id.state not in ('done', 'cancel') and ml.move_id.picking_id.immediate_transfer and not ml.product_uom_qty).mapped('move_id')
+ for move in moves:
+ move.product_uom_qty = move.quantity_done
+ next_moves._do_unreserve()
+ next_moves._action_assign()
+
+ if moves_to_recompute_state:
+ moves_to_recompute_state._recompute_state()
+
+ return res
+
+ def unlink(self):
+ precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+ for ml in self:
+ if ml.state in ('done', 'cancel'):
+ raise UserError(_('You can not delete product moves if the picking is done. You can only correct the done quantities.'))
+ # Unlinking a move line should unreserve.
+ if ml.product_id.type == 'product' and not ml._should_bypass_reservation(ml.location_id) and not float_is_zero(ml.product_qty, precision_digits=precision):
+ self.env['stock.quant']._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
+ moves = self.mapped('move_id')
+ res = super(StockMoveLine, self).unlink()
+ if moves:
+ # Add with_prefetch() to set the _prefecht_ids = _ids
+ # because _prefecht_ids generator look lazily on the cache of move_id
+ # which is clear by the unlink of move line
+ moves.with_prefetch()._recompute_state()
+ return res
+
+ def _action_done(self):
+ """ This method is called during a move's `action_done`. It'll actually move a quant from
+ the source location to the destination location, and unreserve if needed in the source
+ location.
+
+ This method is intended to be called on all the move lines of a move. This method is not
+ intended to be called when editing a `done` move (that's what the override of `write` here
+ is done.
+ """
+ Quant = self.env['stock.quant']
+
+ # First, we loop over all the move lines to do a preliminary check: `qty_done` should not
+ # be negative and, according to the presence of a picking type or a linked inventory
+ # adjustment, enforce some rules on the `lot_id` field. If `qty_done` is null, we unlink
+ # the line. It is mandatory in order to free the reservation and correctly apply
+ # `action_done` on the next move lines.
+ ml_ids_tracked_without_lot = OrderedSet()
+ ml_ids_to_delete = OrderedSet()
+ ml_ids_to_create_lot = OrderedSet()
+ for ml in self:
+ # Check here if `ml.qty_done` respects the rounding of `ml.product_uom_id`.
+ uom_qty = float_round(ml.qty_done, precision_rounding=ml.product_uom_id.rounding, rounding_method='HALF-UP')
+ precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+ qty_done = float_round(ml.qty_done, precision_digits=precision_digits, rounding_method='HALF-UP')
+ if float_compare(uom_qty, qty_done, precision_digits=precision_digits) != 0:
+ raise UserError(_('The quantity done for the product "%s" doesn\'t respect the rounding precision \
+ defined on the unit of measure "%s". Please change the quantity done or the \
+ rounding precision of your unit of measure.') % (ml.product_id.display_name, ml.product_uom_id.name))
+
+ qty_done_float_compared = float_compare(ml.qty_done, 0, precision_rounding=ml.product_uom_id.rounding)
+ if qty_done_float_compared > 0:
+ if ml.product_id.tracking != 'none':
+ picking_type_id = ml.move_id.picking_type_id
+ if picking_type_id:
+ if picking_type_id.use_create_lots:
+ # If a picking type is linked, we may have to create a production lot on
+ # the fly before assigning it to the move line if the user checked both
+ # `use_create_lots` and `use_existing_lots`.
+ if ml.lot_name and not ml.lot_id:
+ lot = self.env['stock.production.lot'].search([
+ ('company_id', '=', ml.company_id.id),
+ ('product_id', '=', ml.product_id.id),
+ ('name', '=', ml.lot_name),
+ ], limit=1)
+ if lot:
+ ml.lot_id = lot.id
+ else:
+ ml_ids_to_create_lot.add(ml.id)
+ elif not picking_type_id.use_create_lots and not picking_type_id.use_existing_lots:
+ # If the user disabled both `use_create_lots` and `use_existing_lots`
+ # checkboxes on the picking type, he's allowed to enter tracked
+ # products without a `lot_id`.
+ continue
+ elif ml.move_id.inventory_id:
+ # If an inventory adjustment is linked, the user is allowed to enter
+ # tracked products without a `lot_id`.
+ continue
+
+ if not ml.lot_id and ml.id not in ml_ids_to_create_lot:
+ ml_ids_tracked_without_lot.add(ml.id)
+ elif qty_done_float_compared < 0:
+ raise UserError(_('No negative quantities allowed'))
+ else:
+ ml_ids_to_delete.add(ml.id)
+
+ if ml_ids_tracked_without_lot:
+ mls_tracked_without_lot = self.env['stock.move.line'].browse(ml_ids_tracked_without_lot)
+ raise UserError(_('You need to supply a Lot/Serial Number for product: \n - ') +
+ '\n - '.join(mls_tracked_without_lot.mapped('product_id.display_name')))
+ ml_to_create_lot = self.env['stock.move.line'].browse(ml_ids_to_create_lot)
+ ml_to_create_lot._create_and_assign_production_lot()
+
+ mls_to_delete = self.env['stock.move.line'].browse(ml_ids_to_delete)
+ mls_to_delete.unlink()
+
+ mls_todo = (self - mls_to_delete)
+ mls_todo._check_company()
+
+ # Now, we can actually move the quant.
+ ml_ids_to_ignore = OrderedSet()
+ for ml in mls_todo:
+ if ml.product_id.type == 'product':
+ rounding = ml.product_uom_id.rounding
+
+ # if this move line is force assigned, unreserve elsewhere if needed
+ if not ml._should_bypass_reservation(ml.location_id) and float_compare(ml.qty_done, ml.product_uom_qty, precision_rounding=rounding) > 0:
+ qty_done_product_uom = ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id, rounding_method='HALF-UP')
+ extra_qty = qty_done_product_uom - ml.product_qty
+ ml_to_ignore = self.env['stock.move.line'].browse(ml_ids_to_ignore)
+ ml._free_reservation(ml.product_id, ml.location_id, extra_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, ml_to_ignore=ml_to_ignore)
+ # unreserve what's been reserved
+ if not ml._should_bypass_reservation(ml.location_id) and ml.product_id.type == 'product' and ml.product_qty:
+ Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
+
+ # move what's been actually done
+ quantity = ml.product_uom_id._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP')
+ available_qty, in_date = Quant._update_available_quantity(ml.product_id, ml.location_id, -quantity, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id)
+ if available_qty < 0 and ml.lot_id:
+ # see if we can compensate the negative quants with some untracked quants
+ untracked_qty = Quant._get_available_quantity(ml.product_id, ml.location_id, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
+ if untracked_qty:
+ taken_from_untracked_qty = min(untracked_qty, abs(quantity))
+ Quant._update_available_quantity(ml.product_id, ml.location_id, -taken_from_untracked_qty, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id)
+ Quant._update_available_quantity(ml.product_id, ml.location_id, taken_from_untracked_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id)
+ Quant._update_available_quantity(ml.product_id, ml.location_dest_id, quantity, lot_id=ml.lot_id, package_id=ml.result_package_id, owner_id=ml.owner_id, in_date=in_date)
+ ml_ids_to_ignore.add(ml.id)
+ # Reset the reserved quantity as we just moved it to the destination location.
+ mls_todo.with_context(bypass_reservation_update=True).write({
+ 'product_uom_qty': 0.00,
+ 'date': fields.Datetime.now(),
+ })
+
+ def _get_similar_move_lines(self):
+ self.ensure_one()
+ lines = self.env['stock.move.line']
+ picking_id = self.move_id.picking_id if self.move_id else self.picking_id
+ if picking_id:
+ lines |= picking_id.move_line_ids.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name))
+ return lines
+
+ def _create_and_assign_production_lot(self):
+ """ Creates and assign new production lots for move lines."""
+ lot_vals = [{
+ 'company_id': ml.move_id.company_id.id,
+ 'name': ml.lot_name,
+ 'product_id': ml.product_id.id,
+ } for ml in self]
+ lots = self.env['stock.production.lot'].create(lot_vals)
+ for ml, lot in zip(self, lots):
+ ml._assign_production_lot(lot)
+
+ def _assign_production_lot(self, lot):
+ self.ensure_one()
+ self.write({
+ 'lot_id': lot.id
+ })
+
+ def _reservation_is_updatable(self, quantity, reserved_quant):
+ self.ensure_one()
+ if (self.product_id.tracking != 'serial' and
+ self.location_id.id == reserved_quant.location_id.id and
+ self.lot_id.id == reserved_quant.lot_id.id and
+ self.package_id.id == reserved_quant.package_id.id and
+ self.owner_id.id == reserved_quant.owner_id.id):
+ return True
+ return False
+
+ def _log_message(self, record, move, template, vals):
+ data = vals.copy()
+ if 'lot_id' in vals and vals['lot_id'] != move.lot_id.id:
+ data['lot_name'] = self.env['stock.production.lot'].browse(vals.get('lot_id')).name
+ if 'location_id' in vals:
+ data['location_name'] = self.env['stock.location'].browse(vals.get('location_id')).name
+ if 'location_dest_id' in vals:
+ data['location_dest_name'] = self.env['stock.location'].browse(vals.get('location_dest_id')).name
+ if 'package_id' in vals and vals['package_id'] != move.package_id.id:
+ data['package_name'] = self.env['stock.quant.package'].browse(vals.get('package_id')).name
+ if 'package_result_id' in vals and vals['package_result_id'] != move.package_result_id.id:
+ data['result_package_name'] = self.env['stock.quant.package'].browse(vals.get('result_package_id')).name
+ if 'owner_id' in vals and vals['owner_id'] != move.owner_id.id:
+ data['owner_name'] = self.env['res.partner'].browse(vals.get('owner_id')).name
+ record.message_post_with_view(template, values={'move': move, 'vals': dict(vals, **data)}, subtype_id=self.env.ref('mail.mt_note').id)
+
+ def _free_reservation(self, product_id, location_id, quantity, lot_id=None, package_id=None, owner_id=None, ml_to_ignore=None):
+ """ When editing a done move line or validating one with some forced quantities, it is
+ possible to impact quants that were not reserved. It is therefore necessary to edit or
+ unlink the move lines that reserved a quantity now unavailable.
+
+ :param ml_to_ignore: recordset of `stock.move.line` that should NOT be unreserved
+ """
+ self.ensure_one()
+
+ if ml_to_ignore is None:
+ ml_to_ignore = self.env['stock.move.line']
+ ml_to_ignore |= self
+
+ # Check the available quantity, with the `strict` kw set to `True`. If the available
+ # quantity is greather than the quantity now unavailable, there is nothing to do.
+ available_quantity = self.env['stock.quant']._get_available_quantity(
+ product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=True
+ )
+ if quantity > available_quantity:
+ # We now have to find the move lines that reserved our now unavailable quantity. We
+ # take care to exclude ourselves and the move lines were work had already been done.
+ outdated_move_lines_domain = [
+ ('state', 'not in', ['done', 'cancel']),
+ ('product_id', '=', product_id.id),
+ ('lot_id', '=', lot_id.id if lot_id else False),
+ ('location_id', '=', location_id.id),
+ ('owner_id', '=', owner_id.id if owner_id else False),
+ ('package_id', '=', package_id.id if package_id else False),
+ ('product_qty', '>', 0.0),
+ ('id', 'not in', ml_to_ignore.ids),
+ ]
+ # We take the current picking first, then the pickings with the latest scheduled date
+ current_picking_first = lambda cand: (
+ cand.picking_id != self.move_id.picking_id,
+ -(cand.picking_id.scheduled_date or cand.move_id.date).timestamp()
+ if cand.picking_id or cand.move_id
+ else -cand.id,
+ )
+ outdated_candidates = self.env['stock.move.line'].search(outdated_move_lines_domain).sorted(current_picking_first)
+
+ # As the move's state is not computed over the move lines, we'll have to manually
+ # recompute the moves which we adapted their lines.
+ move_to_recompute_state = self.env['stock.move']
+ to_unlink_candidate_ids = set()
+
+ rounding = self.product_uom_id.rounding
+ for candidate in outdated_candidates:
+ if float_compare(candidate.product_qty, quantity, precision_rounding=rounding) <= 0:
+ quantity -= candidate.product_qty
+ if candidate.qty_done:
+ move_to_recompute_state |= candidate.move_id
+ candidate.product_uom_qty = 0.0
+ else:
+ to_unlink_candidate_ids.add(candidate.id)
+ if float_is_zero(quantity, precision_rounding=rounding):
+ break
+ else:
+ # split this move line and assign the new part to our extra move
+ quantity_split = float_round(
+ candidate.product_qty - quantity,
+ precision_rounding=self.product_uom_id.rounding,
+ rounding_method='UP')
+ candidate.product_uom_qty = self.product_id.uom_id._compute_quantity(quantity_split, candidate.product_uom_id, rounding_method='HALF-UP')
+ move_to_recompute_state |= candidate.move_id
+ break
+ self.env['stock.move.line'].browse(to_unlink_candidate_ids).unlink()
+ move_to_recompute_state._recompute_state()
+
+ def _should_bypass_reservation(self, location):
+ self.ensure_one()
+ return location.should_bypass_reservation() or self.product_id.type != 'product'
+
+ def _get_aggregated_product_quantities(self, **kwargs):
+ """ Returns a dictionary of products (key = id+name+description+uom) and corresponding values of interest.
+
+ Allows aggregation of data across separate move lines for the same product. This is expected to be useful
+ in things such as delivery reports. Dict key is made as a combination of values we expect to want to group
+ the products by (i.e. so data is not lost). This function purposely ignores lots/SNs because these are
+ expected to already be properly grouped by line.
+
+ returns: dictionary {product_id+name+description+uom: {product, name, description, qty_done, product_uom}, ...}
+ """
+ aggregated_move_lines = {}
+ for move_line in self:
+ name = move_line.product_id.display_name
+ description = move_line.move_id.description_picking
+ if description == name or description == move_line.product_id.name:
+ description = False
+ uom = move_line.product_uom_id
+ line_key = str(move_line.product_id.id) + "_" + name + (description or "") + "uom " + str(uom.id)
+
+ if line_key not in aggregated_move_lines:
+ aggregated_move_lines[line_key] = {'name': name,
+ 'description': description,
+ 'qty_done': move_line.qty_done,
+ 'product_uom': uom.name,
+ 'product': move_line.product_id}
+ else:
+ aggregated_move_lines[line_key]['qty_done'] += move_line.qty_done
+ return aggregated_move_lines
diff --git a/addons/stock/models/stock_orderpoint.py b/addons/stock/models/stock_orderpoint.py
new file mode 100644
index 00000000..41078221
--- /dev/null
+++ b/addons/stock/models/stock_orderpoint.py
@@ -0,0 +1,517 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+from collections import defaultdict
+from datetime import datetime, time
+from dateutil import relativedelta
+from itertools import groupby
+from json import dumps
+from psycopg2 import OperationalError
+
+from odoo import SUPERUSER_ID, _, api, fields, models, registry
+from odoo.addons.stock.models.stock_rule import ProcurementException
+from odoo.exceptions import UserError, ValidationError
+from odoo.osv import expression
+from odoo.tools import add, float_compare, frozendict, split_every, format_date
+
+_logger = logging.getLogger(__name__)
+
+
+class StockWarehouseOrderpoint(models.Model):
+ """ Defines Minimum stock rules. """
+ _name = "stock.warehouse.orderpoint"
+ _description = "Minimum Inventory Rule"
+ _check_company_auto = True
+ _order = "location_id,company_id,id"
+
+ @api.model
+ def default_get(self, fields):
+ res = super().default_get(fields)
+ warehouse = None
+ if 'warehouse_id' not in res and res.get('company_id'):
+ warehouse = self.env['stock.warehouse'].search([('company_id', '=', res['company_id'])], limit=1)
+ if warehouse:
+ res['warehouse_id'] = warehouse.id
+ res['location_id'] = warehouse.lot_stock_id.id
+ return res
+
+ @api.model
+ def _domain_product_id(self):
+ domain = "('type', '=', 'product')"
+ if self.env.context.get('active_model') == 'product.template':
+ product_template_id = self.env.context.get('active_id', False)
+ domain = f"('product_tmpl_id', '=', {product_template_id})"
+ elif self.env.context.get('default_product_id', False):
+ product_id = self.env.context.get('default_product_id', False)
+ domain = f"('id', '=', {product_id})"
+ return f"[{domain}, '|', ('company_id', '=', False), ('company_id', '=', company_id)]"
+
+ name = fields.Char(
+ 'Name', copy=False, required=True, readonly=True,
+ default=lambda self: self.env['ir.sequence'].next_by_code('stock.orderpoint'))
+ trigger = fields.Selection([
+ ('auto', 'Auto'), ('manual', 'Manual')], string='Trigger', default='auto', required=True)
+ active = fields.Boolean(
+ 'Active', default=True,
+ help="If the active field is set to False, it will allow you to hide the orderpoint without removing it.")
+ snoozed_until = fields.Date('Snoozed', help="Hidden until next scheduler.")
+ warehouse_id = fields.Many2one(
+ 'stock.warehouse', 'Warehouse',
+ check_company=True, ondelete="cascade", required=True)
+ location_id = fields.Many2one(
+ 'stock.location', 'Location', index=True,
+ ondelete="cascade", required=True, check_company=True)
+ product_tmpl_id = fields.Many2one('product.template', related='product_id.product_tmpl_id')
+ product_id = fields.Many2one(
+ 'product.product', 'Product', index=True,
+ domain=lambda self: self._domain_product_id(),
+ ondelete='cascade', required=True, check_company=True)
+ product_category_id = fields.Many2one('product.category', name='Product Category', related='product_id.categ_id', store=True)
+ product_uom = fields.Many2one(
+ 'uom.uom', 'Unit of Measure', related='product_id.uom_id')
+ product_uom_name = fields.Char(string='Product unit of measure label', related='product_uom.display_name', readonly=True)
+ product_min_qty = fields.Float(
+ 'Min Quantity', digits='Product Unit of Measure', required=True, default=0.0,
+ help="When the virtual stock equals to or goes below the Min Quantity specified for this field, Odoo generates "
+ "a procurement to bring the forecasted quantity to the Max Quantity.")
+ product_max_qty = fields.Float(
+ 'Max Quantity', digits='Product Unit of Measure', required=True, default=0.0,
+ help="When the virtual stock goes below the Min Quantity, Odoo generates "
+ "a procurement to bring the forecasted quantity to the Quantity specified as Max Quantity.")
+ qty_multiple = fields.Float(
+ 'Multiple Quantity', digits='Product Unit of Measure',
+ default=1, required=True,
+ help="The procurement quantity will be rounded up to this multiple. If it is 0, the exact quantity will be used.")
+ group_id = fields.Many2one(
+ 'procurement.group', 'Procurement Group', copy=False,
+ help="Moves created through this orderpoint will be put in this procurement group. If none is given, the moves generated by stock rules will be grouped into one big picking.")
+ company_id = fields.Many2one(
+ 'res.company', 'Company', required=True, index=True,
+ default=lambda self: self.env.company)
+ allowed_location_ids = fields.One2many(comodel_name='stock.location', compute='_compute_allowed_location_ids')
+
+ rule_ids = fields.Many2many('stock.rule', string='Rules used', compute='_compute_rules')
+ json_lead_days_popover = fields.Char(compute='_compute_json_popover')
+ lead_days_date = fields.Date(compute='_compute_lead_days')
+ allowed_route_ids = fields.Many2many('stock.location.route', compute='_compute_allowed_route_ids')
+ route_id = fields.Many2one(
+ 'stock.location.route', string='Preferred Route', domain="[('id', 'in', allowed_route_ids)]")
+ qty_on_hand = fields.Float('On Hand', readonly=True, compute='_compute_qty')
+ qty_forecast = fields.Float('Forecast', readonly=True, compute='_compute_qty')
+ qty_to_order = fields.Float('To Order', compute='_compute_qty_to_order', store=True, readonly=False)
+
+
+ _sql_constraints = [
+ ('qty_multiple_check', 'CHECK( qty_multiple >= 0 )', 'Qty Multiple must be greater than or equal to zero.'),
+ ]
+
+ @api.depends('warehouse_id')
+ def _compute_allowed_location_ids(self):
+ loc_domain = [('usage', 'in', ('internal', 'view'))]
+ # We want to keep only the locations
+ # - strictly belonging to our warehouse
+ # - not belonging to any warehouses
+ for orderpoint in self:
+ other_warehouses = self.env['stock.warehouse'].search([('id', '!=', orderpoint.warehouse_id.id)])
+ for view_location_id in other_warehouses.mapped('view_location_id'):
+ loc_domain = expression.AND([loc_domain, ['!', ('id', 'child_of', view_location_id.id)]])
+ loc_domain = expression.AND([loc_domain, ['|', ('company_id', '=', False), ('company_id', '=', orderpoint.company_id.id)]])
+ orderpoint.allowed_location_ids = self.env['stock.location'].search(loc_domain)
+
+ @api.depends('warehouse_id', 'location_id')
+ def _compute_allowed_route_ids(self):
+ route_by_product = self.env['stock.location.route'].search([
+ ('product_selectable', '=', True),
+ ])
+ self.allowed_route_ids = route_by_product.ids
+
+ @api.depends('rule_ids', 'product_id.seller_ids', 'product_id.seller_ids.delay')
+ def _compute_json_popover(self):
+ FloatConverter = self.env['ir.qweb.field.float']
+ for orderpoint in self:
+ if not orderpoint.product_id or not orderpoint.location_id:
+ orderpoint.json_lead_days_popover = False
+ continue
+ dummy, lead_days_description = orderpoint.rule_ids._get_lead_days(orderpoint.product_id)
+ orderpoint.json_lead_days_popover = dumps({
+ 'title': _('Replenishment'),
+ 'icon': 'fa-area-chart',
+ 'popoverTemplate': 'stock.leadDaysPopOver',
+ 'lead_days_date': format_date(self.env, orderpoint.lead_days_date),
+ 'lead_days_description': lead_days_description,
+ 'today': format_date(self.env, fields.Date.today()),
+ 'trigger': orderpoint.trigger,
+ 'qty_forecast': FloatConverter.value_to_html(orderpoint.qty_forecast, {'decimal_precision': 'Product Unit of Measure'}),
+ 'qty_to_order': FloatConverter.value_to_html(orderpoint.qty_to_order, {'decimal_precision': 'Product Unit of Measure'}),
+ 'product_min_qty': FloatConverter.value_to_html(orderpoint.product_min_qty, {'decimal_precision': 'Product Unit of Measure'}),
+ 'product_max_qty': FloatConverter.value_to_html(orderpoint.product_max_qty, {'decimal_precision': 'Product Unit of Measure'}),
+ 'product_uom_name': orderpoint.product_uom_name,
+ 'virtual': orderpoint.trigger == 'manual' and orderpoint.create_uid.id == SUPERUSER_ID,
+ })
+
+ @api.depends('rule_ids', 'product_id.seller_ids', 'product_id.seller_ids.delay')
+ def _compute_lead_days(self):
+ for orderpoint in self.with_context(bypass_delay_description=True):
+ if not orderpoint.product_id or not orderpoint.location_id:
+ orderpoint.lead_days_date = False
+ continue
+ lead_days, dummy = orderpoint.rule_ids._get_lead_days(orderpoint.product_id)
+ lead_days_date = fields.Date.today() + relativedelta.relativedelta(days=lead_days)
+ orderpoint.lead_days_date = lead_days_date
+
+ @api.depends('route_id', 'product_id', 'location_id', 'company_id', 'warehouse_id', 'product_id.route_ids')
+ def _compute_rules(self):
+ for orderpoint in self:
+ if not orderpoint.product_id or not orderpoint.location_id:
+ orderpoint.rule_ids = False
+ continue
+ orderpoint.rule_ids = orderpoint.product_id._get_rules_from_location(orderpoint.location_id, route_ids=orderpoint.route_id)
+
+ @api.constrains('product_id')
+ def _check_product_uom(self):
+ ''' Check if the UoM has the same category as the product standard UoM '''
+ if any(orderpoint.product_id.uom_id.category_id != orderpoint.product_uom.category_id for orderpoint in self):
+ raise ValidationError(_('You have to select a product unit of measure that is in the same category as the default unit of measure of the product'))
+
+ @api.onchange('location_id')
+ def _onchange_location_id(self):
+ warehouse = self.location_id.get_warehouse().id
+ if warehouse:
+ self.warehouse_id = warehouse
+
+ @api.onchange('warehouse_id')
+ def _onchange_warehouse_id(self):
+ """ Finds location id for changed warehouse. """
+ if self.warehouse_id:
+ self.location_id = self.warehouse_id.lot_stock_id.id
+ else:
+ self.location_id = False
+
+ @api.onchange('product_id')
+ def _onchange_product_id(self):
+ if self.product_id:
+ self.product_uom = self.product_id.uom_id.id
+
+ @api.onchange('company_id')
+ def _onchange_company_id(self):
+ if self.company_id:
+ self.warehouse_id = self.env['stock.warehouse'].search([
+ ('company_id', '=', self.company_id.id)
+ ], limit=1)
+
+ def write(self, vals):
+ if 'company_id' in vals:
+ for orderpoint in self:
+ if orderpoint.company_id.id != vals['company_id']:
+ raise UserError(_("Changing the company of this record is forbidden at this point, you should rather archive it and create a new one."))
+ return super().write(vals)
+
+ @api.model
+ def action_open_orderpoints(self):
+ return self._get_orderpoint_action()
+
+ def action_replenish(self):
+ self._procure_orderpoint_confirm(company_id=self.env.company)
+ notification = False
+ if len(self) == 1:
+ notification = self._get_replenishment_order_notification()
+ # Forced to call compute quantity because we don't have a link.
+ self._compute_qty()
+ self.filtered(lambda o: o.create_uid.id == SUPERUSER_ID and o.qty_to_order <= 0.0 and o.trigger == 'manual').unlink()
+ return notification
+
+ def action_replenish_auto(self):
+ self.trigger = 'auto'
+ return self.action_replenish()
+
+ @api.depends('product_id', 'location_id', 'product_id.stock_move_ids', 'product_id.stock_move_ids.state', 'product_id.stock_move_ids.product_uom_qty')
+ def _compute_qty(self):
+ orderpoints_contexts = defaultdict(lambda: self.env['stock.warehouse.orderpoint'])
+ for orderpoint in self:
+ if not orderpoint.product_id or not orderpoint.location_id:
+ orderpoint.qty_on_hand = False
+ orderpoint.qty_forecast = False
+ continue
+ orderpoint_context = orderpoint._get_product_context()
+ product_context = frozendict({**self.env.context, **orderpoint_context})
+ orderpoints_contexts[product_context] |= orderpoint
+ for orderpoint_context, orderpoints_by_context in orderpoints_contexts.items():
+ products_qty = orderpoints_by_context.product_id.with_context(orderpoint_context)._product_available()
+ products_qty_in_progress = orderpoints_by_context._quantity_in_progress()
+ for orderpoint in orderpoints_by_context:
+ orderpoint.qty_on_hand = products_qty[orderpoint.product_id.id]['qty_available']
+ orderpoint.qty_forecast = products_qty[orderpoint.product_id.id]['virtual_available'] + products_qty_in_progress[orderpoint.id]
+
+ @api.depends('qty_multiple', 'qty_forecast', 'product_min_qty', 'product_max_qty')
+ def _compute_qty_to_order(self):
+ for orderpoint in self:
+ if not orderpoint.product_id or not orderpoint.location_id:
+ orderpoint.qty_to_order = False
+ continue
+ qty_to_order = 0.0
+ rounding = orderpoint.product_uom.rounding
+ if float_compare(orderpoint.qty_forecast, orderpoint.product_min_qty, precision_rounding=rounding) < 0:
+ qty_to_order = max(orderpoint.product_min_qty, orderpoint.product_max_qty) - orderpoint.qty_forecast
+
+ remainder = orderpoint.qty_multiple > 0 and qty_to_order % orderpoint.qty_multiple or 0.0
+ if float_compare(remainder, 0.0, precision_rounding=rounding) > 0:
+ qty_to_order += orderpoint.qty_multiple - remainder
+ orderpoint.qty_to_order = qty_to_order
+
+ def _set_default_route_id(self):
+ """ Write the `route_id` field on `self`. This method is intendend to be called on the
+ orderpoints generated when openning the replenish report.
+ """
+ self = self.filtered(lambda o: not o.route_id)
+ rules_groups = self.env['stock.rule'].read_group([
+ ('route_id.product_selectable', '!=', False),
+ ('location_id', 'in', self.location_id.ids),
+ ('action', 'in', ['pull_push', 'pull'])
+ ], ['location_id', 'route_id'], ['location_id', 'route_id'], lazy=False)
+ for g in rules_groups:
+ if not g.get('route_id'):
+ continue
+ orderpoints = self.filtered(lambda o: o.location_id.id == g['location_id'][0])
+ orderpoints.route_id = g['route_id']
+
+ def _get_product_context(self):
+ """Used to call `virtual_available` when running an orderpoint."""
+ self.ensure_one()
+ return {
+ 'location': self.location_id.id,
+ 'to_date': datetime.combine(self.lead_days_date, time.max)
+ }
+
+ def _get_orderpoint_action(self):
+ """Create manual orderpoints for missing product in each warehouses. It also removes
+ orderpoints that have been replenish. In order to do it:
+ - It uses the report.stock.quantity to find missing quantity per product/warehouse
+ - It checks if orderpoint already exist to refill this location.
+ - It checks if it exists other sources (e.g RFQ) tha refill the warehouse.
+ - It creates the orderpoints for missing quantity that were not refill by an upper option.
+
+ return replenish report ir.actions.act_window
+ """
+ action = self.env["ir.actions.actions"]._for_xml_id("stock.action_orderpoint_replenish")
+ action['context'] = self.env.context
+ # Search also with archived ones to avoid to trigger product_location_check SQL constraints later
+ # It means that when there will be a archived orderpoint on a location + product, the replenishment
+ # report won't take in account this location + product and it won't create any manual orderpoint
+ # In master: the active field should be remove
+ orderpoints = self.env['stock.warehouse.orderpoint'].with_context(active_test=False).search([])
+ # Remove previous automatically created orderpoint that has been refilled.
+ to_remove = orderpoints.filtered(lambda o: o.create_uid.id == SUPERUSER_ID and o.qty_to_order <= 0.0 and o.trigger == 'manual')
+ to_remove.unlink()
+ orderpoints = orderpoints - to_remove
+ to_refill = defaultdict(float)
+ all_product_ids = []
+ all_warehouse_ids = []
+ # Take 3 months since it's the max for the forecast report
+ to_date = add(fields.date.today(), months=3)
+ qty_by_product_warehouse = self.env['report.stock.quantity'].read_group(
+ [('date', '=', to_date), ('state', '=', 'forecast')],
+ ['product_id', 'product_qty', 'warehouse_id'],
+ ['product_id', 'warehouse_id'], lazy=False)
+ for group in qty_by_product_warehouse:
+ warehouse_id = group.get('warehouse_id') and group['warehouse_id'][0]
+ if group['product_qty'] >= 0.0 or not warehouse_id:
+ continue
+ all_product_ids.append(group['product_id'][0])
+ all_warehouse_ids.append(warehouse_id)
+ to_refill[(group['product_id'][0], warehouse_id)] = group['product_qty']
+ if not to_refill:
+ return action
+
+ # Recompute the forecasted quantity for missing product today but at this time
+ # with their real lead days.
+ key_to_remove = []
+
+ # group product by lead_days and warehouse in order to read virtual_available
+ # in batch
+ pwh_per_day = defaultdict(list)
+ for (product, warehouse), quantity in to_refill.items():
+ product = self.env['product.product'].browse(product).with_prefetch(all_product_ids)
+ warehouse = self.env['stock.warehouse'].browse(warehouse).with_prefetch(all_warehouse_ids)
+ rules = product._get_rules_from_location(warehouse.lot_stock_id)
+ lead_days = rules.with_context(bypass_delay_description=True)._get_lead_days(product)[0]
+ pwh_per_day[(lead_days, warehouse)].append(product.id)
+ for (days, warehouse), p_ids in pwh_per_day.items():
+ products = self.env['product.product'].browse(p_ids)
+ qties = products.with_context(
+ warehouse=warehouse.id,
+ to_date=fields.datetime.now() + relativedelta.relativedelta(days=days)
+ ).read(['virtual_available'])
+ for qty in qties:
+ if float_compare(qty['virtual_available'], 0, precision_rounding=product.uom_id.rounding) >= 0:
+ key_to_remove.append((qty['id'], warehouse.id))
+ else:
+ to_refill[(qty['id'], warehouse.id)] = qty['virtual_available']
+
+ for key in key_to_remove:
+ del to_refill[key]
+ if not to_refill:
+ return action
+
+ # Remove incoming quantity from other origin than moves (e.g RFQ)
+ product_ids, warehouse_ids = zip(*to_refill)
+ dummy, qty_by_product_wh = self.env['product.product'].browse(product_ids)._get_quantity_in_progress(warehouse_ids=warehouse_ids)
+ rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+ # Group orderpoint by product-warehouse
+ orderpoint_by_product_warehouse = self.env['stock.warehouse.orderpoint'].read_group(
+ [('id', 'in', orderpoints.ids)],
+ ['product_id', 'warehouse_id', 'qty_to_order:sum'],
+ ['product_id', 'warehouse_id'], lazy=False)
+ orderpoint_by_product_warehouse = {
+ (record.get('product_id')[0], record.get('warehouse_id')[0]): record.get('qty_to_order')
+ for record in orderpoint_by_product_warehouse
+ }
+ for (product, warehouse), product_qty in to_refill.items():
+ qty_in_progress = qty_by_product_wh.get((product, warehouse)) or 0.0
+ qty_in_progress += orderpoint_by_product_warehouse.get((product, warehouse), 0.0)
+ # Add qty to order for other orderpoint under this warehouse.
+ if not qty_in_progress:
+ continue
+ to_refill[(product, warehouse)] = product_qty + qty_in_progress
+ to_refill = {k: v for k, v in to_refill.items() if float_compare(
+ v, 0.0, precision_digits=rounding) < 0.0}
+
+ lot_stock_id_by_warehouse = self.env['stock.warehouse'].search_read([
+ ('id', 'in', [g[1] for g in to_refill.keys()])
+ ], ['lot_stock_id'])
+ lot_stock_id_by_warehouse = {w['id']: w['lot_stock_id'][0] for w in lot_stock_id_by_warehouse}
+
+ # With archived ones to avoid `product_location_check` SQL constraints
+ orderpoint_by_product_location = self.env['stock.warehouse.orderpoint'].with_context(active_test=False).read_group(
+ [('id', 'in', orderpoints.ids)],
+ ['product_id', 'location_id', 'ids:array_agg(id)'],
+ ['product_id', 'location_id'], lazy=False)
+ orderpoint_by_product_location = {
+ (record.get('product_id')[0], record.get('location_id')[0]): record.get('ids')[0]
+ for record in orderpoint_by_product_location
+ }
+
+ orderpoint_values_list = []
+ for (product, warehouse), product_qty in to_refill.items():
+ lot_stock_id = lot_stock_id_by_warehouse[warehouse]
+ orderpoint_id = orderpoint_by_product_location.get((product, lot_stock_id))
+ if orderpoint_id:
+ self.env['stock.warehouse.orderpoint'].browse(orderpoint_id).qty_forecast += product_qty
+ else:
+ orderpoint_values = self.env['stock.warehouse.orderpoint']._get_orderpoint_values(product, lot_stock_id)
+ orderpoint_values.update({
+ 'name': _('Replenishment Report'),
+ 'warehouse_id': warehouse,
+ 'company_id': self.env['stock.warehouse'].browse(warehouse).company_id.id,
+ })
+ orderpoint_values_list.append(orderpoint_values)
+
+ orderpoints = self.env['stock.warehouse.orderpoint'].with_user(SUPERUSER_ID).create(orderpoint_values_list)
+ for orderpoint in orderpoints:
+ orderpoint.route_id = orderpoint.product_id.route_ids[:1]
+ orderpoints.filtered(lambda o: not o.route_id)._set_default_route_id()
+ return action
+
+ @api.model
+ def _get_orderpoint_values(self, product, location):
+ return {
+ 'product_id': product,
+ 'location_id': location,
+ 'product_max_qty': 0.0,
+ 'product_min_qty': 0.0,
+ 'trigger': 'manual',
+ }
+
+ def _get_replenishment_order_notification(self):
+ return False
+
+ def _quantity_in_progress(self):
+ """Return Quantities that are not yet in virtual stock but should be deduced from orderpoint rule
+ (example: purchases created from orderpoints)"""
+ return dict(self.mapped(lambda x: (x.id, 0.0)))
+
+ def _prepare_procurement_values(self, date=False, group=False):
+ """ Prepare specific key for moves or other components that will be created from a stock rule
+ comming from an orderpoint. This method could be override in order to add other custom key that could
+ be used in move/po creation.
+ """
+ date_planned = date or fields.Date.today()
+ return {
+ 'route_ids': self.route_id,
+ 'date_planned': date_planned,
+ 'date_deadline': date or False,
+ 'warehouse_id': self.warehouse_id,
+ 'orderpoint_id': self,
+ 'group_id': group or self.group_id,
+ }
+
+ def _procure_orderpoint_confirm(self, use_new_cursor=False, company_id=None, raise_user_error=True):
+ """ Create procurements based on orderpoints.
+ :param bool use_new_cursor: if set, use a dedicated cursor and auto-commit after processing
+ 1000 orderpoints.
+ This is appropriate for batch jobs only.
+ """
+ self = self.with_company(company_id)
+ orderpoints_noprefetch = self.read(['id'])
+ orderpoints_noprefetch = [orderpoint['id'] for orderpoint in orderpoints_noprefetch]
+
+ for orderpoints_batch in split_every(1000, orderpoints_noprefetch):
+ if use_new_cursor:
+ cr = registry(self._cr.dbname).cursor()
+ self = self.with_env(self.env(cr=cr))
+ orderpoints_batch = self.env['stock.warehouse.orderpoint'].browse(orderpoints_batch)
+ orderpoints_exceptions = []
+ while orderpoints_batch:
+ procurements = []
+ for orderpoint in orderpoints_batch:
+ if float_compare(orderpoint.qty_to_order, 0.0, precision_rounding=orderpoint.product_uom.rounding) == 1:
+ date = datetime.combine(orderpoint.lead_days_date, time.min)
+ values = orderpoint._prepare_procurement_values(date=date)
+ procurements.append(self.env['procurement.group'].Procurement(
+ orderpoint.product_id, orderpoint.qty_to_order, orderpoint.product_uom,
+ orderpoint.location_id, orderpoint.name, orderpoint.name,
+ orderpoint.company_id, values))
+
+ try:
+ with self.env.cr.savepoint():
+ self.env['procurement.group'].with_context(from_orderpoint=True).run(procurements, raise_user_error=raise_user_error)
+ except ProcurementException as errors:
+ for procurement, error_msg in errors.procurement_exceptions:
+ orderpoints_exceptions += [(procurement.values.get('orderpoint_id'), error_msg)]
+ failed_orderpoints = self.env['stock.warehouse.orderpoint'].concat(*[o[0] for o in orderpoints_exceptions])
+ if not failed_orderpoints:
+ _logger.error('Unable to process orderpoints')
+ break
+ orderpoints_batch -= failed_orderpoints
+
+ except OperationalError:
+ if use_new_cursor:
+ cr.rollback()
+ continue
+ else:
+ raise
+ else:
+ orderpoints_batch._post_process_scheduler()
+ break
+
+ # Log an activity on product template for failed orderpoints.
+ for orderpoint, error_msg in orderpoints_exceptions:
+ existing_activity = self.env['mail.activity'].search([
+ ('res_id', '=', orderpoint.product_id.product_tmpl_id.id),
+ ('res_model_id', '=', self.env.ref('product.model_product_template').id),
+ ('note', '=', error_msg)])
+ if not existing_activity:
+ orderpoint.product_id.product_tmpl_id.activity_schedule(
+ 'mail.mail_activity_data_warning',
+ note=error_msg,
+ user_id=orderpoint.product_id.responsible_id.id or SUPERUSER_ID,
+ )
+
+ if use_new_cursor:
+ cr.commit()
+ cr.close()
+
+ return {}
+
+ def _post_process_scheduler(self):
+ return True
diff --git a/addons/stock/models/stock_package_level.py b/addons/stock/models/stock_package_level.py
new file mode 100644
index 00000000..008c9c80
--- /dev/null
+++ b/addons/stock/models/stock_package_level.py
@@ -0,0 +1,214 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from itertools import groupby
+from operator import itemgetter
+from collections import defaultdict
+
+from odoo import _, api, fields, models
+
+
+class StockPackageLevel(models.Model):
+ _name = 'stock.package_level'
+ _description = 'Stock Package Level'
+ _check_company_auto = True
+
+ package_id = fields.Many2one(
+ 'stock.quant.package', 'Package', required=True, check_company=True,
+ domain="[('location_id', 'child_of', parent.location_id), '|', ('company_id', '=', False), ('company_id', '=', company_id)]")
+ picking_id = fields.Many2one('stock.picking', 'Picking', check_company=True)
+ move_ids = fields.One2many('stock.move', 'package_level_id')
+ move_line_ids = fields.One2many('stock.move.line', 'package_level_id')
+ location_id = fields.Many2one('stock.location', 'From', compute='_compute_location_id', check_company=True)
+ location_dest_id = fields.Many2one(
+ 'stock.location', 'To', check_company=True,
+ domain="[('id', 'child_of', parent.location_dest_id), '|', ('company_id', '=', False), ('company_id', '=', company_id)]")
+ is_done = fields.Boolean('Done', compute='_compute_is_done', inverse='_set_is_done')
+ state = fields.Selection([
+ ('draft', 'Draft'),
+ ('confirmed', 'Confirmed'),
+ ('assigned', 'Reserved'),
+ ('new', 'New'),
+ ('done', 'Done'),
+ ('cancel', 'Cancelled'),
+ ],string='State', compute='_compute_state')
+ is_fresh_package = fields.Boolean(compute='_compute_fresh_pack')
+
+ picking_type_code = fields.Selection(related='picking_id.picking_type_code')
+ show_lots_m2o = fields.Boolean(compute='_compute_show_lot')
+ show_lots_text = fields.Boolean(compute='_compute_show_lot')
+ company_id = fields.Many2one('res.company', 'Company', required=True, index=True)
+
+ @api.depends('move_line_ids', 'move_line_ids.qty_done')
+ def _compute_is_done(self):
+ for package_level in self:
+ # If it is an existing package
+ if package_level.is_fresh_package:
+ package_level.is_done = True
+ else:
+ package_level.is_done = package_level._check_move_lines_map_quant_package(package_level.package_id)
+
+ def _set_is_done(self):
+ for package_level in self:
+ if package_level.is_done:
+ if not package_level.is_fresh_package:
+ ml_update_dict = defaultdict(float)
+ for quant in package_level.package_id.quant_ids:
+ corresponding_ml = package_level.move_line_ids.filtered(lambda ml: ml.product_id == quant.product_id and ml.lot_id == quant.lot_id)
+ if corresponding_ml:
+ ml_update_dict[corresponding_ml[0]] += quant.quantity
+ else:
+ corresponding_move = package_level.move_ids.filtered(lambda m: m.product_id == quant.product_id)[:1]
+ self.env['stock.move.line'].create({
+ 'location_id': package_level.location_id.id,
+ 'location_dest_id': package_level.location_dest_id.id,
+ 'picking_id': package_level.picking_id.id,
+ 'product_id': quant.product_id.id,
+ 'qty_done': quant.quantity,
+ 'product_uom_id': quant.product_id.uom_id.id,
+ 'lot_id': quant.lot_id.id,
+ 'package_id': package_level.package_id.id,
+ 'result_package_id': package_level.package_id.id,
+ 'package_level_id': package_level.id,
+ 'move_id': corresponding_move.id,
+ 'owner_id': quant.owner_id.id,
+ })
+ for rec, quant in ml_update_dict.items():
+ rec.qty_done = quant
+ else:
+ package_level.move_line_ids.filtered(lambda ml: ml.product_qty == 0).unlink()
+ package_level.move_line_ids.filtered(lambda ml: ml.product_qty != 0).write({'qty_done': 0})
+
+ @api.depends('move_line_ids', 'move_line_ids.package_id', 'move_line_ids.result_package_id')
+ def _compute_fresh_pack(self):
+ for package_level in self:
+ if not package_level.move_line_ids or all(ml.package_id and ml.package_id == ml.result_package_id for ml in package_level.move_line_ids):
+ package_level.is_fresh_package = False
+ else:
+ package_level.is_fresh_package = True
+
+ @api.depends('move_ids', 'move_ids.state', 'move_line_ids', 'move_line_ids.state')
+ def _compute_state(self):
+ for package_level in self:
+ if not package_level.move_ids and not package_level.move_line_ids:
+ package_level.state = 'draft'
+ elif not package_level.move_line_ids and package_level.move_ids.filtered(lambda m: m.state not in ('done', 'cancel')):
+ package_level.state = 'confirmed'
+ elif package_level.move_line_ids and not package_level.move_line_ids.filtered(lambda ml: ml.state == 'done'):
+ if package_level.is_fresh_package:
+ package_level.state = 'new'
+ elif package_level._check_move_lines_map_quant_package(package_level.package_id, 'product_uom_qty'):
+ package_level.state = 'assigned'
+ else:
+ package_level.state = 'confirmed'
+ elif package_level.move_line_ids.filtered(lambda ml: ml.state =='done'):
+ package_level.state = 'done'
+ elif package_level.move_line_ids.filtered(lambda ml: ml.state == 'cancel') or package_level.move_ids.filtered(lambda m: m.state == 'cancel'):
+ package_level.state = 'cancel'
+ else:
+ package_level.state = 'draft'
+
+ def _compute_show_lot(self):
+ for package_level in self:
+ if any(ml.product_id.tracking != 'none' for ml in package_level.move_line_ids):
+ if package_level.picking_id.picking_type_id.use_existing_lots or package_level.state == 'done':
+ package_level.show_lots_m2o = True
+ package_level.show_lots_text = False
+ else:
+ if self.picking_id.picking_type_id.use_create_lots and package_level.state != 'done':
+ package_level.show_lots_m2o = False
+ package_level.show_lots_text = True
+ else:
+ package_level.show_lots_m2o = False
+ package_level.show_lots_text = False
+ else:
+ package_level.show_lots_m2o = False
+ package_level.show_lots_text = False
+
+ def _generate_moves(self):
+ for package_level in self:
+ if package_level.package_id:
+ for quant in package_level.package_id.quant_ids:
+ self.env['stock.move'].create({
+ 'picking_id': package_level.picking_id.id,
+ 'name': quant.product_id.display_name,
+ 'product_id': quant.product_id.id,
+ 'product_uom_qty': quant.quantity,
+ 'product_uom': quant.product_id.uom_id.id,
+ 'location_id': package_level.location_id.id,
+ 'location_dest_id': package_level.location_dest_id.id,
+ 'package_level_id': package_level.id,
+ 'company_id': package_level.company_id.id,
+ })
+
+ @api.model
+ def create(self, vals):
+ result = super(StockPackageLevel, self).create(vals)
+ if vals.get('location_dest_id'):
+ result.mapped('move_line_ids').write({'location_dest_id': vals['location_dest_id']})
+ result.mapped('move_ids').write({'location_dest_id': vals['location_dest_id']})
+ return result
+
+ def write(self, vals):
+ result = super(StockPackageLevel, self).write(vals)
+ if vals.get('location_dest_id'):
+ self.mapped('move_line_ids').write({'location_dest_id': vals['location_dest_id']})
+ self.mapped('move_ids').write({'location_dest_id': vals['location_dest_id']})
+ return result
+
+ def unlink(self):
+ self.mapped('move_ids').write({'package_level_id': False})
+ self.mapped('move_line_ids').write({'result_package_id': False})
+ return super(StockPackageLevel, self).unlink()
+
+ def _check_move_lines_map_quant_package(self, package, field='qty_done'):
+ """ should compare in good uom """
+ all_in = True
+ pack_move_lines = self.move_line_ids
+ keys = ['product_id', 'lot_id']
+
+ def sorted_key(object):
+ object.ensure_one()
+ return [object.product_id.id, object.lot_id.id]
+
+ grouped_quants = {}
+ for k, g in groupby(sorted(package.quant_ids, key=sorted_key), key=itemgetter(*keys)):
+ grouped_quants[k] = sum(self.env['stock.quant'].concat(*list(g)).mapped('quantity'))
+
+ grouped_ops = {}
+ for k, g in groupby(sorted(pack_move_lines, key=sorted_key), key=itemgetter(*keys)):
+ grouped_ops[k] = sum(self.env['stock.move.line'].concat(*list(g)).mapped(field))
+ if any(grouped_quants.get(key, 0) - grouped_ops.get(key, 0) != 0 for key in grouped_quants) \
+ or any(grouped_ops.get(key, 0) - grouped_quants.get(key, 0) != 0 for key in grouped_ops):
+ all_in = False
+ return all_in
+
+ @api.depends('package_id', 'state', 'is_fresh_package', 'move_ids', 'move_line_ids')
+ def _compute_location_id(self):
+ for pl in self:
+ if pl.state == 'new' or pl.is_fresh_package:
+ pl.location_id = False
+ elif pl.package_id:
+ pl.location_id = pl.package_id.location_id
+ elif pl.state == 'confirmed' and pl.move_ids:
+ pl.location_id = pl.move_ids[0].location_id
+ elif pl.state in ('assigned', 'done') and pl.move_line_ids:
+ pl.location_id = pl.move_line_ids[0].location_id
+ else:
+ pl.location_id = pl.picking_id.location_id
+
+ def action_show_package_details(self):
+ self.ensure_one()
+ view = self.env.ref('stock.package_level_form_edit_view', raise_if_not_found=False) or self.env.ref('stock.package_level_form_view')
+
+ return {
+ 'name': _('Package Content'),
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'stock.package_level',
+ 'views': [(view.id, 'form')],
+ 'view_id': view.id,
+ 'target': 'new',
+ 'res_id': self.id,
+ 'flags': {'mode': 'readonly'},
+ }
diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py
new file mode 100644
index 00000000..cc2b4345
--- /dev/null
+++ b/addons/stock/models/stock_picking.py
@@ -0,0 +1,1409 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import json
+import time
+from ast import literal_eval
+from collections import defaultdict
+from datetime import date
+from itertools import groupby
+from operator import attrgetter, itemgetter
+from collections import defaultdict
+
+from odoo import SUPERUSER_ID, _, api, fields, models
+from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES
+from odoo.exceptions import UserError
+from odoo.osv import expression
+from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, format_datetime
+from odoo.tools.float_utils import float_compare, float_is_zero, float_round
+from odoo.tools.misc import format_date
+
+
+class PickingType(models.Model):
+ _name = "stock.picking.type"
+ _description = "Picking Type"
+ _order = 'sequence, id'
+ _check_company_auto = True
+
+ def _default_show_operations(self):
+ return self.user_has_groups('stock.group_production_lot,'
+ 'stock.group_stock_multi_locations,'
+ 'stock.group_tracking_lot')
+
+ name = fields.Char('Operation Type', required=True, translate=True)
+ color = fields.Integer('Color')
+ sequence = fields.Integer('Sequence', help="Used to order the 'All Operations' kanban view")
+ sequence_id = fields.Many2one(
+ 'ir.sequence', 'Reference Sequence',
+ check_company=True, copy=False)
+ sequence_code = fields.Char('Code', required=True)
+ default_location_src_id = fields.Many2one(
+ 'stock.location', 'Default Source Location',
+ check_company=True,
+ help="This is the default source location when you create a picking manually with this operation type. It is possible however to change it or that the routes put another location. If it is empty, it will check for the supplier location on the partner. ")
+ default_location_dest_id = fields.Many2one(
+ 'stock.location', 'Default Destination Location',
+ check_company=True,
+ help="This is the default destination location when you create a picking manually with this operation type. It is possible however to change it or that the routes put another location. If it is empty, it will check for the customer location on the partner. ")
+ code = fields.Selection([('incoming', 'Receipt'), ('outgoing', 'Delivery'), ('internal', 'Internal Transfer')], 'Type of Operation', required=True)
+ return_picking_type_id = fields.Many2one(
+ 'stock.picking.type', 'Operation Type for Returns',
+ check_company=True)
+ show_entire_packs = fields.Boolean('Move Entire Packages', help="If ticked, you will be able to select entire packages to move")
+ warehouse_id = fields.Many2one(
+ 'stock.warehouse', 'Warehouse', ondelete='cascade',
+ check_company=True)
+ active = fields.Boolean('Active', default=True)
+ use_create_lots = fields.Boolean(
+ 'Create New Lots/Serial Numbers', default=True,
+ help="If this is checked only, it will suppose you want to create new Lots/Serial Numbers, so you can provide them in a text field. ")
+ use_existing_lots = fields.Boolean(
+ 'Use Existing Lots/Serial Numbers', default=True,
+ help="If this is checked, you will be able to choose the Lots/Serial Numbers. You can also decide to not put lots in this operation type. This means it will create stock with no lot or not put a restriction on the lot taken. ")
+ show_operations = fields.Boolean(
+ 'Show Detailed Operations', default=_default_show_operations,
+ help="If this checkbox is ticked, the pickings lines will represent detailed stock operations. If not, the picking lines will represent an aggregate of detailed stock operations.")
+ show_reserved = fields.Boolean(
+ 'Pre-fill Detailed Operations', default=True,
+ help="If this checkbox is ticked, Odoo will automatically pre-fill the detailed "
+ "operations with the corresponding products, locations and lot/serial numbers.")
+
+ count_picking_draft = fields.Integer(compute='_compute_picking_count')
+ count_picking_ready = fields.Integer(compute='_compute_picking_count')
+ count_picking = fields.Integer(compute='_compute_picking_count')
+ count_picking_waiting = fields.Integer(compute='_compute_picking_count')
+ count_picking_late = fields.Integer(compute='_compute_picking_count')
+ count_picking_backorders = fields.Integer(compute='_compute_picking_count')
+ rate_picking_late = fields.Integer(compute='_compute_picking_count')
+ rate_picking_backorders = fields.Integer(compute='_compute_picking_count')
+ barcode = fields.Char('Barcode', copy=False)
+ company_id = fields.Many2one(
+ 'res.company', 'Company', required=True,
+ default=lambda s: s.env.company.id, index=True)
+
+ @api.model
+ def create(self, vals):
+ if 'sequence_id' not in vals or not vals['sequence_id']:
+ if vals['warehouse_id']:
+ wh = self.env['stock.warehouse'].browse(vals['warehouse_id'])
+ vals['sequence_id'] = self.env['ir.sequence'].sudo().create({
+ 'name': wh.name + ' ' + _('Sequence') + ' ' + vals['sequence_code'],
+ 'prefix': wh.code + '/' + vals['sequence_code'] + '/', 'padding': 5,
+ 'company_id': wh.company_id.id,
+ }).id
+ else:
+ vals['sequence_id'] = self.env['ir.sequence'].create({
+ 'name': _('Sequence') + ' ' + vals['sequence_code'],
+ 'prefix': vals['sequence_code'], 'padding': 5,
+ 'company_id': vals.get('company_id') or self.env.company.id,
+ }).id
+
+ picking_type = super(PickingType, self).create(vals)
+ return picking_type
+
+ def write(self, vals):
+ if 'company_id' in vals:
+ for picking_type in self:
+ if picking_type.company_id.id != vals['company_id']:
+ raise UserError(_("Changing the company of this record is forbidden at this point, you should rather archive it and create a new one."))
+ if 'sequence_code' in vals:
+ for picking_type in self:
+ if picking_type.warehouse_id:
+ picking_type.sequence_id.write({
+ 'name': picking_type.warehouse_id.name + ' ' + _('Sequence') + ' ' + vals['sequence_code'],
+ 'prefix': picking_type.warehouse_id.code + '/' + vals['sequence_code'] + '/', 'padding': 5,
+ 'company_id': picking_type.warehouse_id.company_id.id,
+ })
+ else:
+ picking_type.sequence_id.write({
+ 'name': _('Sequence') + ' ' + vals['sequence_code'],
+ 'prefix': vals['sequence_code'], 'padding': 5,
+ 'company_id': picking_type.env.company.id,
+ })
+ return super(PickingType, self).write(vals)
+
+ def _compute_picking_count(self):
+ # TDE TODO count picking can be done using previous two
+ domains = {
+ 'count_picking_draft': [('state', '=', 'draft')],
+ 'count_picking_waiting': [('state', 'in', ('confirmed', 'waiting'))],
+ 'count_picking_ready': [('state', '=', 'assigned')],
+ 'count_picking': [('state', 'in', ('assigned', 'waiting', 'confirmed'))],
+ 'count_picking_late': [('scheduled_date', '<', time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)), ('state', 'in', ('assigned', 'waiting', 'confirmed'))],
+ 'count_picking_backorders': [('backorder_id', '!=', False), ('state', 'in', ('confirmed', 'assigned', 'waiting'))],
+ }
+ for field in domains:
+ data = self.env['stock.picking'].read_group(domains[field] +
+ [('state', 'not in', ('done', 'cancel')), ('picking_type_id', 'in', self.ids)],
+ ['picking_type_id'], ['picking_type_id'])
+ count = {
+ x['picking_type_id'][0]: x['picking_type_id_count']
+ for x in data if x['picking_type_id']
+ }
+ for record in self:
+ record[field] = count.get(record.id, 0)
+ for record in self:
+ record.rate_picking_late = record.count_picking and record.count_picking_late * 100 / record.count_picking or 0
+ record.rate_picking_backorders = record.count_picking and record.count_picking_backorders * 100 / record.count_picking or 0
+
+ def name_get(self):
+ """ Display 'Warehouse_name: PickingType_name' """
+ res = []
+ for picking_type in self:
+ if picking_type.warehouse_id:
+ name = picking_type.warehouse_id.name + ': ' + picking_type.name
+ else:
+ name = picking_type.name
+ res.append((picking_type.id, name))
+ return res
+
+ @api.model
+ def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
+ args = args or []
+ domain = []
+ if name:
+ domain = ['|', ('name', operator, name), ('warehouse_id.name', operator, name)]
+ return self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
+
+ @api.onchange('code')
+ def _onchange_picking_code(self):
+ warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.company_id.id)], limit=1)
+ stock_location = warehouse.lot_stock_id
+ self.show_operations = self.code != 'incoming' and self.user_has_groups(
+ 'stock.group_production_lot,'
+ 'stock.group_stock_multi_locations,'
+ 'stock.group_tracking_lot'
+ )
+ if self.code == 'incoming':
+ self.default_location_src_id = self.env.ref('stock.stock_location_suppliers').id
+ self.default_location_dest_id = stock_location.id
+ elif self.code == 'outgoing':
+ self.default_location_src_id = stock_location.id
+ self.default_location_dest_id = self.env.ref('stock.stock_location_customers').id
+ elif self.code == 'internal' and not self.user_has_groups('stock.group_stock_multi_locations'):
+ return {
+ 'warning': {
+ 'message': _('You need to activate storage locations to be able to do internal operation types.')
+ }
+ }
+
+ @api.onchange('company_id')
+ def _onchange_company_id(self):
+ if self.company_id:
+ warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.company_id.id)], limit=1)
+ self.warehouse_id = warehouse
+ else:
+ self.warehouse_id = False
+
+ @api.onchange('show_operations')
+ def _onchange_show_operations(self):
+ if self.show_operations and self.code != 'incoming':
+ self.show_reserved = True
+
+ def _get_action(self, action_xmlid):
+ action = self.env["ir.actions.actions"]._for_xml_id(action_xmlid)
+ if self:
+ action['display_name'] = self.display_name
+
+ default_immediate_tranfer = True
+ if self.env['ir.config_parameter'].sudo().get_param('stock.no_default_immediate_tranfer'):
+ default_immediate_tranfer = False
+
+ context = {
+ 'search_default_picking_type_id': [self.id],
+ 'default_picking_type_id': self.id,
+ 'default_immediate_transfer': default_immediate_tranfer,
+ 'default_company_id': self.company_id.id,
+ }
+
+ action_context = literal_eval(action['context'])
+ context = {**action_context, **context}
+ action['context'] = context
+ return action
+
+ def get_action_picking_tree_late(self):
+ return self._get_action('stock.action_picking_tree_late')
+
+ def get_action_picking_tree_backorder(self):
+ return self._get_action('stock.action_picking_tree_backorder')
+
+ def get_action_picking_tree_waiting(self):
+ return self._get_action('stock.action_picking_tree_waiting')
+
+ def get_action_picking_tree_ready(self):
+ return self._get_action('stock.action_picking_tree_ready')
+
+ def get_stock_picking_action_picking_type(self):
+ return self._get_action('stock.stock_picking_action_picking_type')
+
+
+class Picking(models.Model):
+ _name = "stock.picking"
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+ _description = "Transfer"
+ _order = "priority desc, scheduled_date asc, id desc"
+
+ name = fields.Char(
+ 'Reference', default='/',
+ copy=False, index=True, readonly=True)
+ origin = fields.Char(
+ 'Source Document', index=True,
+ states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
+ help="Reference of the document")
+ note = fields.Text('Notes')
+ backorder_id = fields.Many2one(
+ 'stock.picking', 'Back Order of',
+ copy=False, index=True, readonly=True,
+ check_company=True,
+ help="If this shipment was split, then this field links to the shipment which contains the already processed part.")
+ backorder_ids = fields.One2many('stock.picking', 'backorder_id', 'Back Orders')
+ move_type = fields.Selection([
+ ('direct', 'As soon as possible'), ('one', 'When all products are ready')], 'Shipping Policy',
+ default='direct', required=True,
+ states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
+ help="It specifies goods to be deliver partially or all at once")
+ state = fields.Selection([
+ ('draft', 'Draft'),
+ ('waiting', 'Waiting Another Operation'),
+ ('confirmed', 'Waiting'),
+ ('assigned', 'Ready'),
+ ('done', 'Done'),
+ ('cancel', 'Cancelled'),
+ ], string='Status', compute='_compute_state',
+ copy=False, index=True, readonly=True, store=True, tracking=True,
+ help=" * Draft: The transfer is not confirmed yet. Reservation doesn't apply.\n"
+ " * Waiting another operation: This transfer is waiting for another operation before being ready.\n"
+ " * Waiting: The transfer is waiting for the availability of some products.\n(a) The shipping policy is \"As soon as possible\": no product could be reserved.\n(b) The shipping policy is \"When all products are ready\": not all the products could be reserved.\n"
+ " * Ready: The transfer is ready to be processed.\n(a) The shipping policy is \"As soon as possible\": at least one product has been reserved.\n(b) The shipping policy is \"When all products are ready\": all product have been reserved.\n"
+ " * Done: The transfer has been processed.\n"
+ " * Cancelled: The transfer has been cancelled.")
+ group_id = fields.Many2one(
+ 'procurement.group', 'Procurement Group',
+ readonly=True, related='move_lines.group_id', store=True)
+ priority = fields.Selection(
+ PROCUREMENT_PRIORITIES, string='Priority', default='0', index=True,
+ help="Products will be reserved first for the transfers with the highest priorities.")
+ scheduled_date = fields.Datetime(
+ 'Scheduled Date', compute='_compute_scheduled_date', inverse='_set_scheduled_date', store=True,
+ index=True, default=fields.Datetime.now, tracking=True,
+ states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
+ help="Scheduled time for the first part of the shipment to be processed. Setting manually a value here would set it as expected date for all the stock moves.")
+ date_deadline = fields.Datetime(
+ "Deadline", compute='_compute_date_deadline', store=True,
+ help="Date Promise to the customer on the top level document (SO/PO)")
+ has_deadline_issue = fields.Boolean(
+ "Is late", compute='_compute_has_deadline_issue', store=True, default=False,
+ help="Is late or will be late depending on the deadline and scheduled date")
+ date = fields.Datetime(
+ 'Creation Date',
+ default=fields.Datetime.now, index=True, tracking=True,
+ states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
+ help="Creation Date, usually the time of the order")
+ date_done = fields.Datetime('Date of Transfer', copy=False, readonly=True, help="Date at which the transfer has been processed or cancelled.")
+ delay_alert_date = fields.Datetime('Delay Alert Date', compute='_compute_delay_alert_date', search='_search_delay_alert_date')
+ json_popover = fields.Char('JSON data for the popover widget', compute='_compute_json_popover')
+ location_id = fields.Many2one(
+ 'stock.location', "Source Location",
+ default=lambda self: self.env['stock.picking.type'].browse(self._context.get('default_picking_type_id')).default_location_src_id,
+ check_company=True, readonly=True, required=True,
+ states={'draft': [('readonly', False)]})
+ location_dest_id = fields.Many2one(
+ 'stock.location', "Destination Location",
+ default=lambda self: self.env['stock.picking.type'].browse(self._context.get('default_picking_type_id')).default_location_dest_id,
+ check_company=True, readonly=True, required=True,
+ states={'draft': [('readonly', False)]})
+ move_lines = fields.One2many('stock.move', 'picking_id', string="Stock Moves", copy=True)
+ move_ids_without_package = fields.One2many('stock.move', 'picking_id', string="Stock moves not in package", compute='_compute_move_without_package', inverse='_set_move_without_package')
+ has_scrap_move = fields.Boolean(
+ 'Has Scrap Moves', compute='_has_scrap_move')
+ picking_type_id = fields.Many2one(
+ 'stock.picking.type', 'Operation Type',
+ required=True, readonly=True,
+ states={'draft': [('readonly', False)]})
+ picking_type_code = fields.Selection(
+ related='picking_type_id.code',
+ readonly=True)
+ picking_type_entire_packs = fields.Boolean(related='picking_type_id.show_entire_packs',
+ readonly=True)
+ hide_picking_type = fields.Boolean(compute='_compute_hide_pickign_type')
+ partner_id = fields.Many2one(
+ 'res.partner', 'Contact',
+ check_company=True,
+ states={'done': [('readonly', True)], 'cancel': [('readonly', True)]})
+ company_id = fields.Many2one(
+ 'res.company', string='Company', related='picking_type_id.company_id',
+ readonly=True, store=True, index=True)
+ user_id = fields.Many2one(
+ 'res.users', 'Responsible', tracking=True,
+ domain=lambda self: [('groups_id', 'in', self.env.ref('stock.group_stock_user').id)],
+ states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
+ default=lambda self: self.env.user)
+ move_line_ids = fields.One2many('stock.move.line', 'picking_id', 'Operations')
+ move_line_ids_without_package = fields.One2many('stock.move.line', 'picking_id', 'Operations without package', domain=['|',('package_level_id', '=', False), ('picking_type_entire_packs', '=', False)])
+ move_line_nosuggest_ids = fields.One2many('stock.move.line', 'picking_id', domain=[('product_qty', '=', 0.0)])
+ move_line_exist = fields.Boolean(
+ 'Has Pack Operations', compute='_compute_move_line_exist',
+ help='Check the existence of pack operation on the picking')
+ has_packages = fields.Boolean(
+ 'Has Packages', compute='_compute_has_packages',
+ help='Check the existence of destination packages on move lines')
+ show_check_availability = fields.Boolean(
+ compute='_compute_show_check_availability',
+ help='Technical field used to compute whether the button "Check Availability" should be displayed.')
+ show_mark_as_todo = fields.Boolean(
+ compute='_compute_show_mark_as_todo',
+ help='Technical field used to compute whether the button "Mark as Todo" should be displayed.')
+ show_validate = fields.Boolean(
+ compute='_compute_show_validate',
+ help='Technical field used to decide whether the button "Validate" should be displayed.')
+ use_create_lots = fields.Boolean(related='picking_type_id.use_create_lots')
+ owner_id = fields.Many2one(
+ 'res.partner', 'Assign Owner',
+ states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
+ check_company=True,
+ help="When validating the transfer, the products will be assigned to this owner.")
+ printed = fields.Boolean('Printed', copy=False)
+ signature = fields.Image('Signature', help='Signature', copy=False, attachment=True)
+ is_locked = fields.Boolean(default=True, help='When the picking is not done this allows changing the '
+ 'initial demand. When the picking is done this allows '
+ 'changing the done quantities.')
+ # Used to search on pickings
+ product_id = fields.Many2one('product.product', 'Product', related='move_lines.product_id', readonly=True)
+ show_operations = fields.Boolean(compute='_compute_show_operations')
+ show_reserved = fields.Boolean(related='picking_type_id.show_reserved')
+ show_lots_text = fields.Boolean(compute='_compute_show_lots_text')
+ has_tracking = fields.Boolean(compute='_compute_has_tracking')
+ immediate_transfer = fields.Boolean(default=False)
+ package_level_ids = fields.One2many('stock.package_level', 'picking_id')
+ package_level_ids_details = fields.One2many('stock.package_level', 'picking_id')
+ products_availability = fields.Char(
+ string="Product Availability", compute='_compute_products_availability')
+ products_availability_state = fields.Selection([
+ ('available', 'Available'),
+ ('expected', 'Expected'),
+ ('late', 'Late')], compute='_compute_products_availability')
+
+ _sql_constraints = [
+ ('name_uniq', 'unique(name, company_id)', 'Reference must be unique per company!'),
+ ]
+
+ def _compute_has_tracking(self):
+ for picking in self:
+ picking.has_tracking = any(m.has_tracking != 'none' for m in picking.move_lines)
+
+ @api.depends('date_deadline', 'scheduled_date')
+ def _compute_has_deadline_issue(self):
+ for picking in self:
+ picking.has_deadline_issue = picking.date_deadline and picking.date_deadline < picking.scheduled_date or False
+
+ def _compute_hide_pickign_type(self):
+ self.hide_picking_type = self.env.context.get('default_picking_type_id', False)
+
+ @api.depends('move_lines.delay_alert_date')
+ def _compute_delay_alert_date(self):
+ delay_alert_date_data = self.env['stock.move'].read_group([('id', 'in', self.move_lines.ids), ('delay_alert_date', '!=', False)], ['delay_alert_date:max'], 'picking_id')
+ delay_alert_date_data = {data['picking_id'][0]: data['delay_alert_date'] for data in delay_alert_date_data}
+ for picking in self:
+ picking.delay_alert_date = delay_alert_date_data.get(picking.id, False)
+
+ @api.depends('move_lines', 'state', 'picking_type_code', 'move_lines.forecast_availability', 'move_lines.forecast_expected_date')
+ def _compute_products_availability(self):
+ self.products_availability = False
+ self.products_availability_state = 'available'
+ pickings = self.filtered(lambda picking: picking.state not in ['cancel', 'draft', 'done'] and picking.picking_type_code == 'outgoing')
+ pickings.products_availability = _('Available')
+ for picking in pickings:
+ forecast_date = max(picking.move_lines.filtered('forecast_expected_date').mapped('forecast_expected_date'), default=False)
+ if any(float_compare(move.forecast_availability, move.product_qty, precision_rounding=move.product_id.uom_id.rounding) == -1 for move in picking.move_lines):
+ picking.products_availability = _('Not Available')
+ picking.products_availability_state = 'late'
+ elif forecast_date:
+ picking.products_availability = _('Exp %s', format_date(self.env, forecast_date))
+ picking.products_availability_state = 'late' if picking.date_deadline and picking.date_deadline < forecast_date else 'expected'
+
+ @api.depends('picking_type_id.show_operations')
+ def _compute_show_operations(self):
+ for picking in self:
+ if self.env.context.get('force_detailed_view'):
+ picking.show_operations = True
+ continue
+ if picking.picking_type_id.show_operations:
+ if (picking.state == 'draft' and picking.immediate_transfer) or picking.state != 'draft':
+ picking.show_operations = True
+ else:
+ picking.show_operations = False
+ else:
+ picking.show_operations = False
+
+ @api.depends('move_line_ids', 'picking_type_id.use_create_lots', 'picking_type_id.use_existing_lots', 'state')
+ def _compute_show_lots_text(self):
+ group_production_lot_enabled = self.user_has_groups('stock.group_production_lot')
+ for picking in self:
+ if not picking.move_line_ids and not picking.picking_type_id.use_create_lots:
+ picking.show_lots_text = False
+ elif group_production_lot_enabled and picking.picking_type_id.use_create_lots \
+ and not picking.picking_type_id.use_existing_lots and picking.state != 'done':
+ picking.show_lots_text = True
+ else:
+ picking.show_lots_text = False
+
+ def _compute_json_popover(self):
+ for picking in self:
+ if picking.state in ('done', 'cancel') or not picking.delay_alert_date:
+ picking.json_popover = False
+ continue
+ picking.json_popover = json.dumps({
+ 'popoverTemplate': 'stock.PopoverStockRescheduling',
+ 'delay_alert_date': format_datetime(self.env, picking.delay_alert_date, dt_format=False) if picking.delay_alert_date else False,
+ 'late_elements': [{
+ 'id': late_move.id,
+ 'name': late_move.display_name,
+ 'model': late_move._name,
+ } for late_move in picking.move_lines.filtered(lambda m: m.delay_alert_date).move_orig_ids._delay_alert_get_documents()
+ ]
+ })
+
+ @api.depends('move_type', 'immediate_transfer', 'move_lines.state', 'move_lines.picking_id')
+ def _compute_state(self):
+ ''' State of a picking depends on the state of its related stock.move
+ - Draft: only used for "planned pickings"
+ - Waiting: if the picking is not ready to be sent so if
+ - (a) no quantity could be reserved at all or if
+ - (b) some quantities could be reserved and the shipping policy is "deliver all at once"
+ - Waiting another move: if the picking is waiting for another move
+ - Ready: if the picking is ready to be sent so if:
+ - (a) all quantities are reserved or if
+ - (b) some quantities could be reserved and the shipping policy is "as soon as possible"
+ - Done: if the picking is done.
+ - Cancelled: if the picking is cancelled
+ '''
+ picking_moves_state_map = defaultdict(dict)
+ picking_move_lines = defaultdict(set)
+ for move in self.env['stock.move'].search([('picking_id', 'in', self.ids)]):
+ picking_id = move.picking_id
+ move_state = move.state
+ picking_moves_state_map[picking_id.id].update({
+ 'any_draft': picking_moves_state_map[picking_id.id].get('any_draft', False) or move_state == 'draft',
+ 'all_cancel': picking_moves_state_map[picking_id.id].get('all_cancel', True) and move_state == 'cancel',
+ 'all_cancel_done': picking_moves_state_map[picking_id.id].get('all_cancel_done', True) and move_state in ('cancel', 'done'),
+ })
+ picking_move_lines[picking_id.id].add(move.id)
+ for picking in self:
+ if not picking_moves_state_map[picking.id]:
+ picking.state = 'draft'
+ elif picking_moves_state_map[picking.id]['any_draft']:
+ picking.state = 'draft'
+ elif picking_moves_state_map[picking.id]['all_cancel']:
+ picking.state = 'cancel'
+ elif picking_moves_state_map[picking.id]['all_cancel_done']:
+ picking.state = 'done'
+ else:
+ relevant_move_state = self.env['stock.move'].browse(picking_move_lines[picking.id])._get_relevant_state_among_moves()
+ if picking.immediate_transfer and relevant_move_state not in ('draft', 'cancel', 'done'):
+ picking.state = 'assigned'
+ elif relevant_move_state == 'partially_available':
+ picking.state = 'assigned'
+ else:
+ picking.state = relevant_move_state
+
+ @api.depends('move_lines.state', 'move_lines.date', 'move_type')
+ def _compute_scheduled_date(self):
+ for picking in self:
+ moves_dates = picking.move_lines.filtered(lambda move: move.state not in ('done', 'cancel')).mapped('date')
+ if picking.move_type == 'direct':
+ picking.scheduled_date = min(moves_dates, default=picking.scheduled_date or fields.Datetime.now())
+ else:
+ picking.scheduled_date = max(moves_dates, default=picking.scheduled_date or fields.Datetime.now())
+
+ @api.depends('move_lines.date_deadline', 'move_type')
+ def _compute_date_deadline(self):
+ for picking in self:
+ if picking.move_type == 'direct':
+ picking.date_deadline = min(picking.move_lines.filtered('date_deadline').mapped('date_deadline'), default=False)
+ else:
+ picking.date_deadline = max(picking.move_lines.filtered('date_deadline').mapped('date_deadline'), default=False)
+
+ def _set_scheduled_date(self):
+ for picking in self:
+ if picking.state in ('done', 'cancel'):
+ raise UserError(_("You cannot change the Scheduled Date on a done or cancelled transfer."))
+ picking.move_lines.write({'date': picking.scheduled_date})
+
+ def _has_scrap_move(self):
+ for picking in self:
+ # TDE FIXME: better implementation
+ picking.has_scrap_move = bool(self.env['stock.move'].search_count([('picking_id', '=', picking.id), ('scrapped', '=', True)]))
+
+ def _compute_move_line_exist(self):
+ for picking in self:
+ picking.move_line_exist = bool(picking.move_line_ids)
+
+ def _compute_has_packages(self):
+ domain = [('picking_id', 'in', self.ids), ('result_package_id', '!=', False)]
+ cnt_by_picking = self.env['stock.move.line'].read_group(domain, ['picking_id'], ['picking_id'])
+ cnt_by_picking = {d['picking_id'][0]: d['picking_id_count'] for d in cnt_by_picking}
+ for picking in self:
+ picking.has_packages = bool(cnt_by_picking.get(picking.id, False))
+
+ @api.depends('immediate_transfer', 'state')
+ def _compute_show_check_availability(self):
+ """ According to `picking.show_check_availability`, the "check availability" button will be
+ displayed in the form view of a picking.
+ """
+ for picking in self:
+ if picking.immediate_transfer or picking.state not in ('confirmed', 'waiting', 'assigned'):
+ picking.show_check_availability = False
+ continue
+ picking.show_check_availability = any(
+ move.state in ('waiting', 'confirmed', 'partially_available') and
+ float_compare(move.product_uom_qty, 0, precision_rounding=move.product_uom.rounding)
+ for move in picking.move_lines
+ )
+
+ @api.depends('state', 'move_lines')
+ def _compute_show_mark_as_todo(self):
+ for picking in self:
+ if not picking.move_lines and not picking.package_level_ids:
+ picking.show_mark_as_todo = False
+ elif not picking.immediate_transfer and picking.state == 'draft':
+ picking.show_mark_as_todo = True
+ elif picking.state != 'draft' or not picking.id:
+ picking.show_mark_as_todo = False
+ else:
+ picking.show_mark_as_todo = True
+
+ @api.depends('state')
+ def _compute_show_validate(self):
+ for picking in self:
+ if not (picking.immediate_transfer) and picking.state == 'draft':
+ picking.show_validate = False
+ elif picking.state not in ('draft', 'waiting', 'confirmed', 'assigned'):
+ picking.show_validate = False
+ else:
+ picking.show_validate = True
+
+ @api.model
+ def _search_delay_alert_date(self, operator, value):
+ late_stock_moves = self.env['stock.move'].search([('delay_alert_date', operator, value)])
+ return [('move_lines', 'in', late_stock_moves.ids)]
+
+ @api.onchange('partner_id')
+ def onchange_partner_id(self):
+ for picking in self:
+ picking_id = isinstance(picking.id, int) and picking.id or getattr(picking, '_origin', False) and picking._origin.id
+ if picking_id:
+ moves = self.env['stock.move'].search([('picking_id', '=', picking_id)])
+ for move in moves:
+ move.write({'partner_id': picking.partner_id.id})
+
+ @api.onchange('picking_type_id', 'partner_id')
+ def onchange_picking_type(self):
+ if self.picking_type_id and self.state == 'draft':
+ self = self.with_company(self.company_id)
+ if self.picking_type_id.default_location_src_id:
+ location_id = self.picking_type_id.default_location_src_id.id
+ elif self.partner_id:
+ location_id = self.partner_id.property_stock_supplier.id
+ else:
+ customerloc, location_id = self.env['stock.warehouse']._get_partner_locations()
+
+ if self.picking_type_id.default_location_dest_id:
+ location_dest_id = self.picking_type_id.default_location_dest_id.id
+ elif self.partner_id:
+ location_dest_id = self.partner_id.property_stock_customer.id
+ else:
+ location_dest_id, supplierloc = self.env['stock.warehouse']._get_partner_locations()
+
+ self.location_id = location_id
+ self.location_dest_id = location_dest_id
+ (self.move_lines | self.move_ids_without_package).update({
+ "picking_type_id": self.picking_type_id,
+ "company_id": self.company_id,
+ })
+
+ if self.partner_id and self.partner_id.picking_warn:
+ if self.partner_id.picking_warn == 'no-message' and self.partner_id.parent_id:
+ partner = self.partner_id.parent_id
+ elif self.partner_id.picking_warn not in ('no-message', 'block') and self.partner_id.parent_id.picking_warn == 'block':
+ partner = self.partner_id.parent_id
+ else:
+ partner = self.partner_id
+ if partner.picking_warn != 'no-message':
+ if partner.picking_warn == 'block':
+ self.partner_id = False
+ return {'warning': {
+ 'title': ("Warning for %s") % partner.name,
+ 'message': partner.picking_warn_msg
+ }}
+
+ @api.model
+ def create(self, vals):
+ defaults = self.default_get(['name', 'picking_type_id'])
+ picking_type = self.env['stock.picking.type'].browse(vals.get('picking_type_id', defaults.get('picking_type_id')))
+ if vals.get('name', '/') == '/' and defaults.get('name', '/') == '/' and vals.get('picking_type_id', defaults.get('picking_type_id')):
+ if picking_type.sequence_id:
+ vals['name'] = picking_type.sequence_id.next_by_id()
+
+ # As the on_change in one2many list is WIP, we will overwrite the locations on the stock moves here
+ # As it is a create the format will be a list of (0, 0, dict)
+ moves = vals.get('move_lines', []) + vals.get('move_ids_without_package', [])
+ if moves and vals.get('location_id') and vals.get('location_dest_id'):
+ for move in moves:
+ if len(move) == 3 and move[0] == 0:
+ move[2]['location_id'] = vals['location_id']
+ move[2]['location_dest_id'] = vals['location_dest_id']
+ # When creating a new picking, a move can have no `company_id` (create before
+ # picking type was defined) or a different `company_id` (the picking type was
+ # changed for an another company picking type after the move was created).
+ # So, we define the `company_id` in one of these cases.
+ picking_type = self.env['stock.picking.type'].browse(vals['picking_type_id'])
+ if 'picking_type_id' not in move[2] or move[2]['picking_type_id'] != picking_type.id:
+ move[2]['picking_type_id'] = picking_type.id
+ move[2]['company_id'] = picking_type.company_id.id
+ # make sure to write `schedule_date` *after* the `stock.move` creation in
+ # order to get a determinist execution of `_set_scheduled_date`
+ scheduled_date = vals.pop('scheduled_date', False)
+ res = super(Picking, self).create(vals)
+ if scheduled_date:
+ res.with_context(mail_notrack=True).write({'scheduled_date': scheduled_date})
+ res._autoconfirm_picking()
+
+ # set partner as follower
+ if vals.get('partner_id'):
+ for picking in res.filtered(lambda p: p.location_id.usage == 'supplier' or p.location_dest_id.usage == 'customer'):
+ picking.message_subscribe([vals.get('partner_id')])
+ if vals.get('picking_type_id'):
+ for move in res.move_lines:
+ if not move.description_picking:
+ move.description_picking = move.product_id.with_context(lang=move._get_lang())._get_description(move.picking_id.picking_type_id)
+
+ return res
+
+ def write(self, vals):
+ if vals.get('picking_type_id') and any(picking.state != 'draft' for picking in self):
+ raise UserError(_("Changing the operation type of this record is forbidden at this point."))
+ # set partner as a follower and unfollow old partner
+ if vals.get('partner_id'):
+ for picking in self:
+ if picking.location_id.usage == 'supplier' or picking.location_dest_id.usage == 'customer':
+ if picking.partner_id:
+ picking.message_unsubscribe(picking.partner_id.ids)
+ picking.message_subscribe([vals.get('partner_id')])
+ res = super(Picking, self).write(vals)
+ if vals.get('signature'):
+ for picking in self:
+ picking._attach_sign()
+ # Change locations of moves if those of the picking change
+ after_vals = {}
+ if vals.get('location_id'):
+ after_vals['location_id'] = vals['location_id']
+ if vals.get('location_dest_id'):
+ after_vals['location_dest_id'] = vals['location_dest_id']
+ if after_vals:
+ self.mapped('move_lines').filtered(lambda move: not move.scrapped).write(after_vals)
+ if vals.get('move_lines'):
+ self._autoconfirm_picking()
+
+ return res
+
+ def unlink(self):
+ self.mapped('move_lines')._action_cancel()
+ self.with_context(prefetch_fields=False).mapped('move_lines').unlink() # Checks if moves are not done
+ return super(Picking, self).unlink()
+
+ def action_assign_partner(self):
+ for picking in self:
+ picking.move_lines.write({'partner_id': picking.partner_id.id})
+
+ def do_print_picking(self):
+ self.write({'printed': True})
+ return self.env.ref('stock.action_report_picking').report_action(self)
+
+ def action_confirm(self):
+ self._check_company()
+ self.mapped('package_level_ids').filtered(lambda pl: pl.state == 'draft' and not pl.move_ids)._generate_moves()
+ # call `_action_confirm` on every draft move
+ self.mapped('move_lines')\
+ .filtered(lambda move: move.state == 'draft')\
+ ._action_confirm()
+
+ # run scheduler for moves forecasted to not have enough in stock
+ self.mapped('move_lines').filtered(lambda move: move.state not in ('draft', 'cancel', 'done'))._trigger_scheduler()
+ return True
+
+ def action_assign(self):
+ """ Check availability of picking moves.
+ This has the effect of changing the state and reserve quants on available moves, and may
+ also impact the state of the picking as it is computed based on move's states.
+ @return: True
+ """
+ self.filtered(lambda picking: picking.state == 'draft').action_confirm()
+ moves = self.mapped('move_lines').filtered(lambda move: move.state not in ('draft', 'cancel', 'done'))
+ if not moves:
+ raise UserError(_('Nothing to check the availability for.'))
+ # If a package level is done when confirmed its location can be different than where it will be reserved.
+ # So we remove the move lines created when confirmed to set quantity done to the new reserved ones.
+ package_level_done = self.mapped('package_level_ids').filtered(lambda pl: pl.is_done and pl.state == 'confirmed')
+ package_level_done.write({'is_done': False})
+ moves._action_assign()
+ package_level_done.write({'is_done': True})
+
+ return True
+
+ def action_cancel(self):
+ self.mapped('move_lines')._action_cancel()
+ self.write({'is_locked': True})
+ return True
+
+ def _action_done(self):
+ """Call `_action_done` on the `stock.move` of the `stock.picking` in `self`.
+ This method makes sure every `stock.move.line` is linked to a `stock.move` by either
+ linking them to an existing one or a newly created one.
+
+ If the context key `cancel_backorder` is present, backorders won't be created.
+
+ :return: True
+ :rtype: bool
+ """
+ self._check_company()
+
+ todo_moves = self.mapped('move_lines').filtered(lambda self: self.state in ['draft', 'waiting', 'partially_available', 'assigned', 'confirmed'])
+ for picking in self:
+ if picking.owner_id:
+ picking.move_lines.write({'restrict_partner_id': picking.owner_id.id})
+ picking.move_line_ids.write({'owner_id': picking.owner_id.id})
+ todo_moves._action_done(cancel_backorder=self.env.context.get('cancel_backorder'))
+ self.write({'date_done': fields.Datetime.now(), 'priority': '0'})
+
+ # if incoming moves make other confirmed/partially_available moves available, assign them
+ done_incoming_moves = self.filtered(lambda p: p.picking_type_id.code == 'incoming').move_lines.filtered(lambda m: m.state == 'done')
+ done_incoming_moves._trigger_assign()
+
+ self._send_confirmation_email()
+ return True
+
+ def _send_confirmation_email(self):
+ for stock_pick in self.filtered(lambda p: p.company_id.stock_move_email_validation and p.picking_type_id.code == 'outgoing'):
+ delivery_template_id = stock_pick.company_id.stock_mail_confirmation_template_id.id
+ stock_pick.with_context(force_send=True).message_post_with_template(delivery_template_id, email_layout_xmlid='mail.mail_notification_light')
+
+ @api.depends('state', 'move_lines', 'move_lines.state', 'move_lines.package_level_id', 'move_lines.move_line_ids.package_level_id')
+ def _compute_move_without_package(self):
+ for picking in self:
+ picking.move_ids_without_package = picking._get_move_ids_without_package()
+
+ def _set_move_without_package(self):
+ new_mwp = self[0].move_ids_without_package
+ for picking in self:
+ old_mwp = picking._get_move_ids_without_package()
+ picking.move_lines = (picking.move_lines - old_mwp) | new_mwp
+ moves_to_unlink = old_mwp - new_mwp
+ if moves_to_unlink:
+ moves_to_unlink.unlink()
+
+ def _get_move_ids_without_package(self):
+ self.ensure_one()
+ move_ids_without_package = self.env['stock.move']
+ if not self.picking_type_entire_packs:
+ move_ids_without_package = self.move_lines
+ else:
+ for move in self.move_lines:
+ if not move.package_level_id:
+ if move.state == 'assigned' and move.picking_id and not move.picking_id.immediate_transfer or move.state == 'done':
+ if any(not ml.package_level_id for ml in move.move_line_ids):
+ move_ids_without_package |= move
+ else:
+ move_ids_without_package |= move
+ return move_ids_without_package.filtered(lambda move: not move.scrap_ids)
+
+ def _check_move_lines_map_quant_package(self, package):
+ """ This method checks that all product of the package (quant) are well present in the move_line_ids of the picking. """
+ all_in = True
+ pack_move_lines = self.move_line_ids.filtered(lambda ml: ml.package_id == package)
+ keys = ['product_id', 'lot_id']
+ keys_ids = ["{}.id".format(fname) for fname in keys]
+ precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+
+ grouped_quants = {}
+ for k, g in groupby(sorted(package.quant_ids, key=attrgetter(*keys_ids)), key=itemgetter(*keys)):
+ grouped_quants[k] = sum(self.env['stock.quant'].concat(*list(g)).mapped('quantity'))
+
+ grouped_ops = {}
+ for k, g in groupby(sorted(pack_move_lines, key=attrgetter(*keys_ids)), key=itemgetter(*keys)):
+ grouped_ops[k] = sum(self.env['stock.move.line'].concat(*list(g)).mapped('product_qty'))
+ if any(not float_is_zero(grouped_quants.get(key, 0) - grouped_ops.get(key, 0), precision_digits=precision_digits) for key in grouped_quants) \
+ or any(not float_is_zero(grouped_ops.get(key, 0) - grouped_quants.get(key, 0), precision_digits=precision_digits) for key in grouped_ops):
+ all_in = False
+ return all_in
+
+ def _get_entire_pack_location_dest(self, move_line_ids):
+ location_dest_ids = move_line_ids.mapped('location_dest_id')
+ if len(location_dest_ids) > 1:
+ return False
+ return location_dest_ids.id
+
+ def _check_entire_pack(self):
+ """ This function check if entire packs are moved in the picking"""
+ for picking in self:
+ origin_packages = picking.move_line_ids.mapped("package_id")
+ for pack in origin_packages:
+ if picking._check_move_lines_map_quant_package(pack):
+ package_level_ids = picking.package_level_ids.filtered(lambda pl: pl.package_id == pack)
+ move_lines_to_pack = picking.move_line_ids.filtered(lambda ml: ml.package_id == pack and not ml.result_package_id)
+ if not package_level_ids:
+ self.env['stock.package_level'].create({
+ 'picking_id': picking.id,
+ 'package_id': pack.id,
+ 'location_id': pack.location_id.id,
+ 'location_dest_id': self._get_entire_pack_location_dest(move_lines_to_pack) or picking.location_dest_id.id,
+ 'move_line_ids': [(6, 0, move_lines_to_pack.ids)],
+ 'company_id': picking.company_id.id,
+ })
+ # TODO: in master, move package field in `stock` and clean code.
+ if pack._allowed_to_move_between_transfers():
+ move_lines_to_pack.write({
+ 'result_package_id': pack.id,
+ })
+ else:
+ move_lines_in_package_level = move_lines_to_pack.filtered(lambda ml: ml.move_id.package_level_id)
+ move_lines_without_package_level = move_lines_to_pack - move_lines_in_package_level
+ for ml in move_lines_in_package_level:
+ ml.write({
+ 'result_package_id': pack.id,
+ 'package_level_id': ml.move_id.package_level_id.id,
+ })
+ move_lines_without_package_level.write({
+ 'result_package_id': pack.id,
+ 'package_level_id': package_level_ids[0].id,
+ })
+ for pl in package_level_ids:
+ pl.location_dest_id = self._get_entire_pack_location_dest(pl.move_line_ids) or picking.location_dest_id.id
+
+ def do_unreserve(self):
+ self.move_lines._do_unreserve()
+ self.package_level_ids.filtered(lambda p: not p.move_ids).unlink()
+
+ def button_validate(self):
+ # Clean-up the context key at validation to avoid forcing the creation of immediate
+ # transfers.
+ ctx = dict(self.env.context)
+ ctx.pop('default_immediate_transfer', None)
+ self = self.with_context(ctx)
+
+ # Sanity checks.
+ pickings_without_moves = self.browse()
+ pickings_without_quantities = self.browse()
+ pickings_without_lots = self.browse()
+ products_without_lots = self.env['product.product']
+ for picking in self:
+ if not picking.move_lines and not picking.move_line_ids:
+ pickings_without_moves |= picking
+
+ picking.message_subscribe([self.env.user.partner_id.id])
+ picking_type = picking.picking_type_id
+ precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+ no_quantities_done = all(float_is_zero(move_line.qty_done, precision_digits=precision_digits) for move_line in picking.move_line_ids.filtered(lambda m: m.state not in ('done', 'cancel')))
+ no_reserved_quantities = all(float_is_zero(move_line.product_qty, precision_rounding=move_line.product_uom_id.rounding) for move_line in picking.move_line_ids)
+ if no_reserved_quantities and no_quantities_done:
+ pickings_without_quantities |= picking
+
+ if picking_type.use_create_lots or picking_type.use_existing_lots:
+ lines_to_check = picking.move_line_ids
+ if not no_quantities_done:
+ lines_to_check = lines_to_check.filtered(lambda line: float_compare(line.qty_done, 0, precision_rounding=line.product_uom_id.rounding))
+ for line in lines_to_check:
+ product = line.product_id
+ if product and product.tracking != 'none':
+ if not line.lot_name and not line.lot_id:
+ pickings_without_lots |= picking
+ products_without_lots |= product
+
+ if not self._should_show_transfers():
+ if pickings_without_moves:
+ raise UserError(_('Please add some items to move.'))
+ if pickings_without_quantities:
+ raise UserError(self._get_without_quantities_error_message())
+ if pickings_without_lots:
+ raise UserError(_('You need to supply a Lot/Serial number for products %s.') % ', '.join(products_without_lots.mapped('display_name')))
+ else:
+ message = ""
+ if pickings_without_moves:
+ message += _('Transfers %s: Please add some items to move.') % ', '.join(pickings_without_moves.mapped('name'))
+ if pickings_without_quantities:
+ message += _('\n\nTransfers %s: You cannot validate these transfers if no quantities are reserved nor done. To force these transfers, switch in edit more and encode the done quantities.') % ', '.join(pickings_without_quantities.mapped('name'))
+ if pickings_without_lots:
+ message += _('\n\nTransfers %s: You need to supply a Lot/Serial number for products %s.') % (', '.join(pickings_without_lots.mapped('name')), ', '.join(products_without_lots.mapped('display_name')))
+ if message:
+ raise UserError(message.lstrip())
+
+ # Run the pre-validation wizards. Processing a pre-validation wizard should work on the
+ # moves and/or the context and never call `_action_done`.
+ if not self.env.context.get('button_validate_picking_ids'):
+ self = self.with_context(button_validate_picking_ids=self.ids)
+ res = self._pre_action_done_hook()
+ if res is not True:
+ return res
+
+ # Call `_action_done`.
+ if self.env.context.get('picking_ids_not_to_backorder'):
+ pickings_not_to_backorder = self.browse(self.env.context['picking_ids_not_to_backorder'])
+ pickings_to_backorder = self - pickings_not_to_backorder
+ else:
+ pickings_not_to_backorder = self.env['stock.picking']
+ pickings_to_backorder = self
+ pickings_not_to_backorder.with_context(cancel_backorder=True)._action_done()
+ pickings_to_backorder.with_context(cancel_backorder=False)._action_done()
+ return True
+
+ def _pre_action_done_hook(self):
+ if not self.env.context.get('skip_immediate'):
+ pickings_to_immediate = self._check_immediate()
+ if pickings_to_immediate:
+ return pickings_to_immediate._action_generate_immediate_wizard(show_transfers=self._should_show_transfers())
+
+ if not self.env.context.get('skip_backorder'):
+ pickings_to_backorder = self._check_backorder()
+ if pickings_to_backorder:
+ return pickings_to_backorder._action_generate_backorder_wizard(show_transfers=self._should_show_transfers())
+ return True
+
+ def _should_show_transfers(self):
+ """Whether the different transfers should be displayed on the pre action done wizards."""
+ return len(self) > 1
+
+ def _get_without_quantities_error_message(self):
+ """ Returns the error message raised in validation if no quantities are reserved or done.
+ The purpose of this method is to be overridden in case we want to adapt this message.
+
+ :return: Translated error message
+ :rtype: str
+ """
+ return _(
+ 'You cannot validate a transfer if no quantities are reserved nor done. '
+ 'To force the transfer, switch in edit mode and encode the done quantities.'
+ )
+
+ def _action_generate_backorder_wizard(self, show_transfers=False):
+ view = self.env.ref('stock.view_backorder_confirmation')
+ return {
+ 'name': _('Create Backorder?'),
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'stock.backorder.confirmation',
+ 'views': [(view.id, 'form')],
+ 'view_id': view.id,
+ 'target': 'new',
+ 'context': dict(self.env.context, default_show_transfers=show_transfers, default_pick_ids=[(4, p.id) for p in self]),
+ }
+
+ def _action_generate_immediate_wizard(self, show_transfers=False):
+ view = self.env.ref('stock.view_immediate_transfer')
+ return {
+ 'name': _('Immediate Transfer?'),
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'stock.immediate.transfer',
+ 'views': [(view.id, 'form')],
+ 'view_id': view.id,
+ 'target': 'new',
+ 'context': dict(self.env.context, default_show_transfers=show_transfers, default_pick_ids=[(4, p.id) for p in self]),
+ }
+
+ def action_toggle_is_locked(self):
+ self.ensure_one()
+ self.is_locked = not self.is_locked
+ return True
+
+ def _check_backorder(self):
+ prec = self.env["decimal.precision"].precision_get("Product Unit of Measure")
+ backorder_pickings = self.browse()
+ for picking in self:
+ quantity_todo = {}
+ quantity_done = {}
+ for move in picking.mapped('move_lines').filtered(lambda m: m.state != "cancel"):
+ quantity_todo.setdefault(move.product_id.id, 0)
+ quantity_done.setdefault(move.product_id.id, 0)
+ quantity_todo[move.product_id.id] += move.product_uom._compute_quantity(move.product_uom_qty, move.product_id.uom_id, rounding_method='HALF-UP')
+ quantity_done[move.product_id.id] += move.product_uom._compute_quantity(move.quantity_done, move.product_id.uom_id, rounding_method='HALF-UP')
+ # FIXME: the next block doesn't seem nor should be used.
+ for ops in picking.mapped('move_line_ids').filtered(lambda x: x.package_id and not x.product_id and not x.move_id):
+ for quant in ops.package_id.quant_ids:
+ quantity_done.setdefault(quant.product_id.id, 0)
+ quantity_done[quant.product_id.id] += quant.qty
+ for pack in picking.mapped('move_line_ids').filtered(lambda x: x.product_id and not x.move_id):
+ quantity_done.setdefault(pack.product_id.id, 0)
+ quantity_done[pack.product_id.id] += pack.product_uom_id._compute_quantity(pack.qty_done, pack.product_id.uom_id)
+ if any(
+ float_compare(quantity_done[x], quantity_todo.get(x, 0), precision_digits=prec,) == -1
+ for x in quantity_done
+ ):
+ backorder_pickings |= picking
+ return backorder_pickings
+
+ def _check_immediate(self):
+ immediate_pickings = self.browse()
+ precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+ for picking in self:
+ if all(float_is_zero(move_line.qty_done, precision_digits=precision_digits) for move_line in picking.move_line_ids.filtered(lambda m: m.state not in ('done', 'cancel'))):
+ immediate_pickings |= picking
+ return immediate_pickings
+
+ def _autoconfirm_picking(self):
+ """ Automatically run `action_confirm` on `self` if the picking is an immediate transfer or
+ if the picking is a planned transfer and one of its move was added after the initial
+ call to `action_confirm`. Note that `action_confirm` will only work on draft moves.
+ """
+ # Clean-up the context key to avoid forcing the creation of immediate transfers.
+ ctx = dict(self.env.context)
+ ctx.pop('default_immediate_transfer', None)
+ self = self.with_context(ctx)
+ for picking in self:
+ if picking.state in ('done', 'cancel'):
+ continue
+ if not picking.move_lines and not picking.package_level_ids:
+ continue
+ if picking.immediate_transfer or any(move.additional for move in picking.move_lines):
+ picking.action_confirm()
+ # Make sure the reservation is bypassed in immediate transfer mode.
+ if picking.immediate_transfer:
+ picking.move_lines.write({'state': 'assigned'})
+
+ def _create_backorder(self):
+ """ This method is called when the user chose to create a backorder. It will create a new
+ picking, the backorder, and move the stock.moves that are not `done` or `cancel` into it.
+ """
+ backorders = self.env['stock.picking']
+ for picking in self:
+ moves_to_backorder = picking.move_lines.filtered(lambda x: x.state not in ('done', 'cancel'))
+ if moves_to_backorder:
+ backorder_picking = picking.copy({
+ 'name': '/',
+ 'move_lines': [],
+ 'move_line_ids': [],
+ 'backorder_id': picking.id
+ })
+ picking.message_post(
+ body=_('The backorder <a href=# data-oe-model=stock.picking data-oe-id=%d>%s</a> has been created.') % (
+ backorder_picking.id, backorder_picking.name))
+ moves_to_backorder.write({'picking_id': backorder_picking.id})
+ moves_to_backorder.mapped('package_level_id').write({'picking_id':backorder_picking.id})
+ moves_to_backorder.mapped('move_line_ids').write({'picking_id': backorder_picking.id})
+ backorders |= backorder_picking
+ return backorders
+
+ def _log_activity_get_documents(self, orig_obj_changes, stream_field, stream, sorted_method=False, groupby_method=False):
+ """ Generic method to log activity. To use with
+ _log_activity method. It either log on uppermost
+ ongoing documents or following documents. This method
+ find all the documents and responsible for which a note
+ has to be log. It also generate a rendering_context in
+ order to render a specific note by documents containing
+ only the information relative to the document it. For example
+ we don't want to notify a picking on move that it doesn't
+ contain.
+
+ :param orig_obj_changes dict: contain a record as key and the
+ change on this record as value.
+ eg: {'move_id': (new product_uom_qty, old product_uom_qty)}
+ :param stream_field string: It has to be a field of the
+ records that are register in the key of 'orig_obj_changes'
+ eg: 'move_dest_ids' if we use move as record (previous example)
+ - 'UP' if we want to log on the upper most ongoing
+ documents.
+ - 'DOWN' if we want to log on following documents.
+ :param sorted_method method, groupby_method: Only need when
+ stream is 'DOWN', it should sort/group by tuple(object on
+ which the activity is log, the responsible for this object)
+ """
+ if self.env.context.get('skip_activity'):
+ return {}
+ move_to_orig_object_rel = {co: ooc for ooc in orig_obj_changes.keys() for co in ooc[stream_field]}
+ origin_objects = self.env[list(orig_obj_changes.keys())[0]._name].concat(*list(orig_obj_changes.keys()))
+ # The purpose here is to group each destination object by
+ # (document to log, responsible) no matter the stream direction.
+ # example:
+ # {'(delivery_picking_1, admin)': stock.move(1, 2)
+ # '(delivery_picking_2, admin)': stock.move(3)}
+ visited_documents = {}
+ if stream == 'DOWN':
+ if sorted_method and groupby_method:
+ grouped_moves = groupby(sorted(origin_objects.mapped(stream_field), key=sorted_method), key=groupby_method)
+ else:
+ raise UserError(_('You have to define a groupby and sorted method and pass them as arguments.'))
+ elif stream == 'UP':
+ # When using upstream document it is required to define
+ # _get_upstream_documents_and_responsibles on
+ # destination objects in order to ascend documents.
+ grouped_moves = {}
+ for visited_move in origin_objects.mapped(stream_field):
+ for document, responsible, visited in visited_move._get_upstream_documents_and_responsibles(self.env[visited_move._name]):
+ if grouped_moves.get((document, responsible)):
+ grouped_moves[(document, responsible)] |= visited_move
+ visited_documents[(document, responsible)] |= visited
+ else:
+ grouped_moves[(document, responsible)] = visited_move
+ visited_documents[(document, responsible)] = visited
+ grouped_moves = grouped_moves.items()
+ else:
+ raise UserError(_('Unknown stream.'))
+
+ documents = {}
+ for (parent, responsible), moves in grouped_moves:
+ if not parent:
+ continue
+ moves = list(moves)
+ moves = self.env[moves[0]._name].concat(*moves)
+ # Get the note
+ rendering_context = {move: (orig_object, orig_obj_changes[orig_object]) for move in moves for orig_object in move_to_orig_object_rel[move]}
+ if visited_documents:
+ documents[(parent, responsible)] = rendering_context, visited_documents.values()
+ else:
+ documents[(parent, responsible)] = rendering_context
+ return documents
+
+ def _log_activity(self, render_method, documents):
+ """ Log a note for each documents, responsible pair in
+ documents passed as argument. The render_method is then
+ call in order to use a template and render it with a
+ rendering_context.
+
+ :param documents dict: A tuple (document, responsible) as key.
+ An activity will be log by key. A rendering_context as value.
+ If used with _log_activity_get_documents. In 'DOWN' stream
+ cases the rendering_context will be a dict with format:
+ {'stream_object': ('orig_object', new_qty, old_qty)}
+ 'UP' stream will add all the documents browsed in order to
+ get the final/upstream document present in the key.
+ :param render_method method: a static function that will generate
+ the html note to log on the activity. The render_method should
+ use the args:
+ - rendering_context dict: value of the documents argument
+ the render_method should return a string with an html format
+ :param stream string:
+ """
+ for (parent, responsible), rendering_context in documents.items():
+ note = render_method(rendering_context)
+ parent.activity_schedule(
+ 'mail.mail_activity_data_warning',
+ date.today(),
+ note=note,
+ user_id=responsible.id or SUPERUSER_ID
+ )
+
+ def _log_less_quantities_than_expected(self, moves):
+ """ Log an activity on picking that follow moves. The note
+ contains the moves changes and all the impacted picking.
+
+ :param dict moves: a dict with a move as key and tuple with
+ new and old quantity as value. eg: {move_1 : (4, 5)}
+ """
+ def _keys_in_sorted(move):
+ """ sort by picking and the responsible for the product the
+ move.
+ """
+ return (move.picking_id.id, move.product_id.responsible_id.id)
+
+ def _keys_in_groupby(move):
+ """ group by picking and the responsible for the product the
+ move.
+ """
+ return (move.picking_id, move.product_id.responsible_id)
+
+ def _render_note_exception_quantity(rendering_context):
+ """ :param rendering_context:
+ {'move_dest': (move_orig, (new_qty, old_qty))}
+ """
+ origin_moves = self.env['stock.move'].browse([move.id for move_orig in rendering_context.values() for move in move_orig[0]])
+ origin_picking = origin_moves.mapped('picking_id')
+ move_dest_ids = self.env['stock.move'].concat(*rendering_context.keys())
+ impacted_pickings = origin_picking._get_impacted_pickings(move_dest_ids) - move_dest_ids.mapped('picking_id')
+ values = {
+ 'origin_picking': origin_picking,
+ 'moves_information': rendering_context.values(),
+ 'impacted_pickings': impacted_pickings,
+ }
+ return self.env.ref('stock.exception_on_picking')._render(values=values)
+
+ documents = self._log_activity_get_documents(moves, 'move_dest_ids', 'DOWN', _keys_in_sorted, _keys_in_groupby)
+ documents = self._less_quantities_than_expected_add_documents(moves, documents)
+ self._log_activity(_render_note_exception_quantity, documents)
+
+ def _less_quantities_than_expected_add_documents(self, moves, documents):
+ return documents
+
+ def _get_impacted_pickings(self, moves):
+ """ This function is used in _log_less_quantities_than_expected
+ the purpose is to notify a user with all the pickings that are
+ impacted by an action on a chained move.
+ param: 'moves' contain moves that belong to a common picking.
+ return: all the pickings that contain a destination moves
+ (direct and indirect) from the moves given as arguments.
+ """
+
+ def _explore(impacted_pickings, explored_moves, moves_to_explore):
+ for move in moves_to_explore:
+ if move not in explored_moves:
+ impacted_pickings |= move.picking_id
+ explored_moves |= move
+ moves_to_explore |= move.move_dest_ids
+ moves_to_explore = moves_to_explore - explored_moves
+ if moves_to_explore:
+ return _explore(impacted_pickings, explored_moves, moves_to_explore)
+ else:
+ return impacted_pickings
+
+ return _explore(self.env['stock.picking'], self.env['stock.move'], moves)
+
+ def _pre_put_in_pack_hook(self, move_line_ids):
+ return self._check_destinations(move_line_ids)
+
+ def _check_destinations(self, move_line_ids):
+ if len(move_line_ids.mapped('location_dest_id')) > 1:
+ view_id = self.env.ref('stock.stock_package_destination_form_view').id
+ wiz = self.env['stock.package.destination'].create({
+ 'picking_id': self.id,
+ 'location_dest_id': move_line_ids[0].location_dest_id.id,
+ })
+ return {
+ 'name': _('Choose destination location'),
+ 'view_mode': 'form',
+ 'res_model': 'stock.package.destination',
+ 'view_id': view_id,
+ 'views': [(view_id, 'form')],
+ 'type': 'ir.actions.act_window',
+ 'res_id': wiz.id,
+ 'target': 'new'
+ }
+ else:
+ return {}
+
+ def _put_in_pack(self, move_line_ids, create_package_level=True):
+ package = False
+ for pick in self:
+ move_lines_to_pack = self.env['stock.move.line']
+ package = self.env['stock.quant.package'].create({})
+
+ precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+ if float_is_zero(move_line_ids[0].qty_done, precision_digits=precision_digits):
+ for line in move_line_ids:
+ line.qty_done = line.product_uom_qty
+
+ for ml in move_line_ids:
+ if float_compare(ml.qty_done, ml.product_uom_qty,
+ precision_rounding=ml.product_uom_id.rounding) >= 0:
+ move_lines_to_pack |= ml
+ else:
+ quantity_left_todo = float_round(
+ ml.product_uom_qty - ml.qty_done,
+ precision_rounding=ml.product_uom_id.rounding,
+ rounding_method='UP')
+ done_to_keep = ml.qty_done
+ new_move_line = ml.copy(
+ default={'product_uom_qty': 0, 'qty_done': ml.qty_done})
+ vals = {'product_uom_qty': quantity_left_todo, 'qty_done': 0.0}
+ if pick.picking_type_id.code == 'incoming':
+ if ml.lot_id:
+ vals['lot_id'] = False
+ if ml.lot_name:
+ vals['lot_name'] = False
+ ml.write(vals)
+ new_move_line.write({'product_uom_qty': done_to_keep})
+ move_lines_to_pack |= new_move_line
+ if create_package_level:
+ package_level = self.env['stock.package_level'].create({
+ 'package_id': package.id,
+ 'picking_id': pick.id,
+ 'location_id': False,
+ 'location_dest_id': move_line_ids.mapped('location_dest_id').id,
+ 'move_line_ids': [(6, 0, move_lines_to_pack.ids)],
+ 'company_id': pick.company_id.id,
+ })
+ move_lines_to_pack.write({
+ 'result_package_id': package.id,
+ })
+ return package
+
+ def action_put_in_pack(self):
+ self.ensure_one()
+ if self.state not in ('done', 'cancel'):
+ picking_move_lines = self.move_line_ids
+ if (
+ not self.picking_type_id.show_reserved
+ and not self.immediate_transfer
+ and not self.env.context.get('barcode_view')
+ ):
+ picking_move_lines = self.move_line_nosuggest_ids
+
+ move_line_ids = picking_move_lines.filtered(lambda ml:
+ float_compare(ml.qty_done, 0.0, precision_rounding=ml.product_uom_id.rounding) > 0
+ and not ml.result_package_id
+ )
+ if not move_line_ids:
+ move_line_ids = picking_move_lines.filtered(lambda ml: float_compare(ml.product_uom_qty, 0.0,
+ precision_rounding=ml.product_uom_id.rounding) > 0 and float_compare(ml.qty_done, 0.0,
+ precision_rounding=ml.product_uom_id.rounding) == 0)
+ if move_line_ids:
+ res = self._pre_put_in_pack_hook(move_line_ids)
+ if not res:
+ res = self._put_in_pack(move_line_ids)
+ return res
+ else:
+ raise UserError(_("Please add 'Done' quantities to the picking to create a new pack."))
+
+ def button_scrap(self):
+ self.ensure_one()
+ view = self.env.ref('stock.stock_scrap_form_view2')
+ products = self.env['product.product']
+ for move in self.move_lines:
+ if move.state not in ('draft', 'cancel') and move.product_id.type in ('product', 'consu'):
+ products |= move.product_id
+ return {
+ 'name': _('Scrap'),
+ 'view_mode': 'form',
+ 'res_model': 'stock.scrap',
+ 'view_id': view.id,
+ 'views': [(view.id, 'form')],
+ 'type': 'ir.actions.act_window',
+ 'context': {'default_picking_id': self.id, 'product_ids': products.ids, 'default_company_id': self.company_id.id},
+ 'target': 'new',
+ }
+
+ def action_see_move_scrap(self):
+ self.ensure_one()
+ action = self.env["ir.actions.actions"]._for_xml_id("stock.action_stock_scrap")
+ scraps = self.env['stock.scrap'].search([('picking_id', '=', self.id)])
+ action['domain'] = [('id', 'in', scraps.ids)]
+ action['context'] = dict(self._context, create=False)
+ return action
+
+ def action_see_packages(self):
+ self.ensure_one()
+ action = self.env["ir.actions.actions"]._for_xml_id("stock.action_package_view")
+ packages = self.move_line_ids.mapped('result_package_id')
+ action['domain'] = [('id', 'in', packages.ids)]
+ action['context'] = {'picking_id': self.id}
+ return action
+
+ def action_picking_move_tree(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("stock.stock_move_action")
+ action['views'] = [
+ (self.env.ref('stock.view_picking_move_tree').id, 'tree'),
+ ]
+ action['context'] = self.env.context
+ action['domain'] = [('picking_id', 'in', self.ids)]
+ return action
+
+ def _attach_sign(self):
+ """ Render the delivery report in pdf and attach it to the picking in `self`. """
+ self.ensure_one()
+ report = self.env.ref('stock.action_report_delivery')._render_qweb_pdf(self.id)
+ filename = "%s_signed_delivery_slip" % self.name
+ if self.partner_id:
+ message = _('Order signed by %s') % (self.partner_id.name)
+ else:
+ message = _('Order signed')
+ self.message_post(
+ attachments=[('%s.pdf' % filename, report[0])],
+ body=message,
+ )
+ return True
diff --git a/addons/stock/models/stock_production_lot.py b/addons/stock/models/stock_production_lot.py
new file mode 100644
index 00000000..e311ad3d
--- /dev/null
+++ b/addons/stock/models/stock_production_lot.py
@@ -0,0 +1,108 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError, ValidationError
+
+
+class ProductionLot(models.Model):
+ _name = 'stock.production.lot'
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+ _description = 'Lot/Serial'
+ _check_company_auto = True
+
+ name = fields.Char(
+ 'Lot/Serial Number', default=lambda self: self.env['ir.sequence'].next_by_code('stock.lot.serial'),
+ required=True, help="Unique Lot/Serial Number")
+ ref = fields.Char('Internal Reference', help="Internal reference number in case it differs from the manufacturer's lot/serial number")
+ product_id = fields.Many2one(
+ 'product.product', 'Product',
+ domain=lambda self: self._domain_product_id(), required=True, check_company=True)
+ product_uom_id = fields.Many2one(
+ 'uom.uom', 'Unit of Measure',
+ related='product_id.uom_id', store=True, readonly=False)
+ quant_ids = fields.One2many('stock.quant', 'lot_id', 'Quants', readonly=True)
+ product_qty = fields.Float('Quantity', compute='_product_qty')
+ note = fields.Html(string='Description')
+ display_complete = fields.Boolean(compute='_compute_display_complete')
+ company_id = fields.Many2one('res.company', 'Company', required=True, store=True, index=True)
+
+ @api.constrains('name', 'product_id', 'company_id')
+ def _check_unique_lot(self):
+ domain = [('product_id', 'in', self.product_id.ids),
+ ('company_id', 'in', self.company_id.ids),
+ ('name', 'in', self.mapped('name'))]
+ fields = ['company_id', 'product_id', 'name']
+ groupby = ['company_id', 'product_id', 'name']
+ records = self.read_group(domain, fields, groupby, lazy=False)
+ error_message_lines = []
+ for rec in records:
+ if rec['__count'] != 1:
+ product_name = self.env['product.product'].browse(rec['product_id'][0]).display_name
+ error_message_lines.append(_(" - Product: %s, Serial Number: %s", product_name, rec['name']))
+ if error_message_lines:
+ raise ValidationError(_('The combination of serial number and product must be unique across a company.\nFollowing combination contains duplicates:\n') + '\n'.join(error_message_lines))
+
+ def _domain_product_id(self):
+ domain = [
+ "('tracking', '!=', 'none')",
+ "('type', '=', 'product')",
+ "'|'",
+ "('company_id', '=', False)",
+ "('company_id', '=', company_id)"
+ ]
+ if self.env.context.get('default_product_tmpl_id'):
+ domain.insert(0,
+ ("('product_tmpl_id', '=', %s)" % self.env.context['default_product_tmpl_id'])
+ )
+ return '[' + ', '.join(domain) + ']'
+
+ def _check_create(self):
+ active_picking_id = self.env.context.get('active_picking_id', False)
+ if active_picking_id:
+ picking_id = self.env['stock.picking'].browse(active_picking_id)
+ if picking_id and not picking_id.picking_type_id.use_create_lots:
+ raise UserError(_('You are not allowed to create a lot or serial number with this operation type. To change this, go on the operation type and tick the box "Create New Lots/Serial Numbers".'))
+
+ @api.depends('name')
+ def _compute_display_complete(self):
+ """ Defines if we want to display all fields in the stock.production.lot form view.
+ It will if the record exists (`id` set) or if we precised it into the context.
+ This compute depends on field `name` because as it has always a default value, it'll be
+ always triggered.
+ """
+ for prod_lot in self:
+ prod_lot.display_complete = prod_lot.id or self._context.get('display_complete')
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ self._check_create()
+ return super(ProductionLot, self).create(vals_list)
+
+ def write(self, vals):
+ if 'company_id' in vals:
+ for lot in self:
+ if lot.company_id.id != vals['company_id']:
+ raise UserError(_("Changing the company of this record is forbidden at this point, you should rather archive it and create a new one."))
+ if 'product_id' in vals and any(vals['product_id'] != lot.product_id.id for lot in self):
+ move_lines = self.env['stock.move.line'].search([('lot_id', 'in', self.ids), ('product_id', '!=', vals['product_id'])])
+ if move_lines:
+ raise UserError(_(
+ 'You are not allowed to change the product linked to a serial or lot number '
+ 'if some stock moves have already been created with that number. '
+ 'This would lead to inconsistencies in your stock.'
+ ))
+ return super(ProductionLot, self).write(vals)
+
+ @api.depends('quant_ids', 'quant_ids.quantity')
+ def _product_qty(self):
+ for lot in self:
+ # We only care for the quants in internal or transit locations.
+ quants = lot.quant_ids.filtered(lambda q: q.location_id.usage == 'internal' or (q.location_id.usage == 'transit' and q.location_id.company_id))
+ lot.product_qty = sum(quants.mapped('quantity'))
+
+ def action_lot_open_quants(self):
+ self = self.with_context(search_default_lot_id=self.id, create=False)
+ if self.user_has_groups('stock.group_stock_manager'):
+ self = self.with_context(inventory_mode=True)
+ return self.env['stock.quant']._get_quants_action()
diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py
new file mode 100644
index 00000000..f9cdeabc
--- /dev/null
+++ b/addons/stock/models/stock_quant.py
@@ -0,0 +1,745 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+
+from psycopg2 import Error, OperationalError
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError, ValidationError
+from odoo.osv import expression
+from odoo.tools.float_utils import float_compare, float_is_zero, float_round
+
+_logger = logging.getLogger(__name__)
+
+
+class StockQuant(models.Model):
+ _name = 'stock.quant'
+ _description = 'Quants'
+ _rec_name = 'product_id'
+
+ def _domain_location_id(self):
+ if not self._is_inventory_mode():
+ return
+ return [('usage', 'in', ['internal', 'transit'])]
+
+ def _domain_lot_id(self):
+ if not self._is_inventory_mode():
+ return
+ domain = [
+ "'|'",
+ "('company_id', '=', company_id)",
+ "('company_id', '=', False)"
+ ]
+ if self.env.context.get('active_model') == 'product.product':
+ domain.insert(0, "('product_id', '=', %s)" % self.env.context.get('active_id'))
+ elif self.env.context.get('active_model') == 'product.template':
+ product_template = self.env['product.template'].browse(self.env.context.get('active_id'))
+ if product_template.exists():
+ domain.insert(0, "('product_id', 'in', %s)" % product_template.product_variant_ids.ids)
+ else:
+ domain.insert(0, "('product_id', '=', product_id)")
+ return '[' + ', '.join(domain) + ']'
+
+ def _domain_product_id(self):
+ if not self._is_inventory_mode():
+ return
+ domain = [('type', '=', 'product')]
+ if self.env.context.get('product_tmpl_ids') or self.env.context.get('product_tmpl_id'):
+ products = self.env.context.get('product_tmpl_ids', []) + [self.env.context.get('product_tmpl_id', 0)]
+ domain = expression.AND([domain, [('product_tmpl_id', 'in', products)]])
+ return domain
+
+ product_id = fields.Many2one(
+ 'product.product', 'Product',
+ domain=lambda self: self._domain_product_id(),
+ ondelete='restrict', readonly=True, required=True, index=True, check_company=True)
+ product_tmpl_id = fields.Many2one(
+ 'product.template', string='Product Template',
+ related='product_id.product_tmpl_id', readonly=False)
+ product_uom_id = fields.Many2one(
+ 'uom.uom', 'Unit of Measure',
+ readonly=True, related='product_id.uom_id')
+ company_id = fields.Many2one(related='location_id.company_id', string='Company', store=True, readonly=True)
+ location_id = fields.Many2one(
+ 'stock.location', 'Location',
+ domain=lambda self: self._domain_location_id(),
+ auto_join=True, ondelete='restrict', readonly=True, required=True, index=True, check_company=True)
+ lot_id = fields.Many2one(
+ 'stock.production.lot', 'Lot/Serial Number', index=True,
+ ondelete='restrict', readonly=True, check_company=True,
+ domain=lambda self: self._domain_lot_id())
+ package_id = fields.Many2one(
+ 'stock.quant.package', 'Package',
+ domain="[('location_id', '=', location_id)]",
+ help='The package containing this quant', readonly=True, ondelete='restrict', check_company=True)
+ owner_id = fields.Many2one(
+ 'res.partner', 'Owner',
+ help='This is the owner of the quant', readonly=True, check_company=True)
+ quantity = fields.Float(
+ 'Quantity',
+ help='Quantity of products in this quant, in the default unit of measure of the product',
+ readonly=True)
+ inventory_quantity = fields.Float(
+ 'Inventoried Quantity', compute='_compute_inventory_quantity',
+ inverse='_set_inventory_quantity', groups='stock.group_stock_manager')
+ reserved_quantity = fields.Float(
+ 'Reserved Quantity',
+ default=0.0,
+ help='Quantity of reserved products in this quant, in the default unit of measure of the product',
+ readonly=True, required=True)
+ available_quantity = fields.Float(
+ 'Available Quantity',
+ help="On hand quantity which hasn't been reserved on a transfer, in the default unit of measure of the product",
+ compute='_compute_available_quantity')
+ in_date = fields.Datetime('Incoming Date', readonly=True)
+ tracking = fields.Selection(related='product_id.tracking', readonly=True)
+ on_hand = fields.Boolean('On Hand', store=False, search='_search_on_hand')
+
+ @api.depends('quantity', 'reserved_quantity')
+ def _compute_available_quantity(self):
+ for quant in self:
+ quant.available_quantity = quant.quantity - quant.reserved_quantity
+
+ @api.depends('quantity')
+ def _compute_inventory_quantity(self):
+ if not self._is_inventory_mode():
+ self.inventory_quantity = 0
+ return
+ for quant in self:
+ quant.inventory_quantity = quant.quantity
+
+ def _set_inventory_quantity(self):
+ """ Inverse method to create stock move when `inventory_quantity` is set
+ (`inventory_quantity` is only accessible in inventory mode).
+ """
+ if not self._is_inventory_mode():
+ return
+ for quant in self:
+ # Get the quantity to create a move for.
+ rounding = quant.product_id.uom_id.rounding
+ diff = float_round(quant.inventory_quantity - quant.quantity, precision_rounding=rounding)
+ diff_float_compared = float_compare(diff, 0, precision_rounding=rounding)
+ # Create and vaidate a move so that the quant matches its `inventory_quantity`.
+ if diff_float_compared == 0:
+ continue
+ elif diff_float_compared > 0:
+ move_vals = quant._get_inventory_move_values(diff, quant.product_id.with_company(quant.company_id).property_stock_inventory, quant.location_id)
+ else:
+ move_vals = quant._get_inventory_move_values(-diff, quant.location_id, quant.product_id.with_company(quant.company_id).property_stock_inventory, out=True)
+ move = quant.env['stock.move'].with_context(inventory_mode=False).create(move_vals)
+ move._action_done()
+
+ def _search_on_hand(self, operator, value):
+ """Handle the "on_hand" filter, indirectly calling `_get_domain_locations`."""
+ if operator not in ['=', '!='] or not isinstance(value, bool):
+ raise UserError(_('Operation not supported'))
+ domain_loc = self.env['product.product']._get_domain_locations()[0]
+ quant_ids = [l['id'] for l in self.env['stock.quant'].search_read(domain_loc, ['id'])]
+ if (operator == '!=' and value is True) or (operator == '=' and value is False):
+ domain_operator = 'not in'
+ else:
+ domain_operator = 'in'
+ return [('id', domain_operator, quant_ids)]
+
+ @api.model
+ def create(self, vals):
+ """ Override to handle the "inventory mode" and create a quant as
+ superuser the conditions are met.
+ """
+ if self._is_inventory_mode() and 'inventory_quantity' in vals:
+ allowed_fields = self._get_inventory_fields_create()
+ if any(field for field in vals.keys() if field not in allowed_fields):
+ raise UserError(_("Quant's creation is restricted, you can't do this operation."))
+ inventory_quantity = vals.pop('inventory_quantity')
+
+ # Create an empty quant or write on a similar one.
+ product = self.env['product.product'].browse(vals['product_id'])
+ location = self.env['stock.location'].browse(vals['location_id'])
+ lot_id = self.env['stock.production.lot'].browse(vals.get('lot_id'))
+ package_id = self.env['stock.quant.package'].browse(vals.get('package_id'))
+ owner_id = self.env['res.partner'].browse(vals.get('owner_id'))
+ quant = self._gather(product, location, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=True)
+ if quant:
+ quant = quant[0]
+ else:
+ quant = self.sudo().create(vals)
+ # Set the `inventory_quantity` field to create the necessary move.
+ quant.inventory_quantity = inventory_quantity
+ return quant
+ res = super(StockQuant, self).create(vals)
+ if self._is_inventory_mode():
+ res._check_company()
+ return res
+
+ @api.model
+ def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
+ """ Override to set the `inventory_quantity` field if we're in "inventory mode" as well
+ as to compute the sum of the `available_quantity` field.
+ """
+ if 'available_quantity' in fields:
+ if 'quantity' not in fields:
+ fields.append('quantity')
+ if 'reserved_quantity' not in fields:
+ fields.append('reserved_quantity')
+ result = super(StockQuant, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
+ for group in result:
+ if self._is_inventory_mode():
+ group['inventory_quantity'] = group.get('quantity', 0)
+ if 'available_quantity' in fields:
+ group['available_quantity'] = group['quantity'] - group['reserved_quantity']
+ return result
+
+ def write(self, vals):
+ """ Override to handle the "inventory mode" and create the inventory move. """
+ allowed_fields = self._get_inventory_fields_write()
+ if self._is_inventory_mode() and any(field for field in allowed_fields if field in vals.keys()):
+ if any(quant.location_id.usage == 'inventory' for quant in self):
+ # Do nothing when user tries to modify manually a inventory loss
+ return
+ if any(field for field in vals.keys() if field not in allowed_fields):
+ raise UserError(_("Quant's editing is restricted, you can't do this operation."))
+ self = self.sudo()
+ return super(StockQuant, self).write(vals)
+ return super(StockQuant, self).write(vals)
+
+ def action_view_stock_moves(self):
+ self.ensure_one()
+ action = self.env["ir.actions.actions"]._for_xml_id("stock.stock_move_line_action")
+ action['domain'] = [
+ ('product_id', '=', self.product_id.id),
+ '|',
+ ('location_id', '=', self.location_id.id),
+ ('location_dest_id', '=', self.location_id.id),
+ ('lot_id', '=', self.lot_id.id),
+ '|',
+ ('package_id', '=', self.package_id.id),
+ ('result_package_id', '=', self.package_id.id),
+ ]
+ return action
+
+ @api.model
+ def action_view_quants(self):
+ self = self.with_context(search_default_internal_loc=1)
+ if not self.user_has_groups('stock.group_stock_multi_locations'):
+ company_user = self.env.company
+ warehouse = self.env['stock.warehouse'].search([('company_id', '=', company_user.id)], limit=1)
+ if warehouse:
+ self = self.with_context(default_location_id=warehouse.lot_stock_id.id)
+
+ # If user have rights to write on quant, we set quants in inventory mode.
+ if self.user_has_groups('stock.group_stock_manager'):
+ self = self.with_context(inventory_mode=True)
+ return self._get_quants_action(extend=True)
+
+ @api.constrains('product_id')
+ def check_product_id(self):
+ if any(elem.product_id.type != 'product' for elem in self):
+ raise ValidationError(_('Quants cannot be created for consumables or services.'))
+
+ @api.constrains('quantity')
+ def check_quantity(self):
+ for quant in self:
+ if float_compare(quant.quantity, 1, precision_rounding=quant.product_uom_id.rounding) > 0 and quant.lot_id and quant.product_id.tracking == 'serial':
+ raise ValidationError(_('The serial number has already been assigned: \n Product: %s, Serial Number: %s') % (quant.product_id.display_name, quant.lot_id.name))
+
+ @api.constrains('location_id')
+ def check_location_id(self):
+ for quant in self:
+ if quant.location_id.usage == 'view':
+ raise ValidationError(_('You cannot take products from or deliver products to a location of type "view" (%s).') % quant.location_id.name)
+
+ @api.model
+ def _get_removal_strategy(self, product_id, location_id):
+ if product_id.categ_id.removal_strategy_id:
+ return product_id.categ_id.removal_strategy_id.method
+ loc = location_id
+ while loc:
+ if loc.removal_strategy_id:
+ return loc.removal_strategy_id.method
+ loc = loc.location_id
+ return 'fifo'
+
+ @api.model
+ def _get_removal_strategy_order(self, removal_strategy):
+ if removal_strategy == 'fifo':
+ return 'in_date ASC NULLS FIRST, id'
+ elif removal_strategy == 'lifo':
+ return 'in_date DESC NULLS LAST, id desc'
+ raise UserError(_('Removal strategy %s not implemented.') % (removal_strategy,))
+
+ def _gather(self, product_id, location_id, lot_id=None, package_id=None, owner_id=None, strict=False):
+ self.env['stock.quant'].flush(['location_id', 'owner_id', 'package_id', 'lot_id', 'product_id'])
+ self.env['product.product'].flush(['virtual_available'])
+ removal_strategy = self._get_removal_strategy(product_id, location_id)
+ removal_strategy_order = self._get_removal_strategy_order(removal_strategy)
+ domain = [
+ ('product_id', '=', product_id.id),
+ ]
+ if not strict:
+ if lot_id:
+ domain = expression.AND([['|', ('lot_id', '=', lot_id.id), ('lot_id', '=', False)], domain])
+ if package_id:
+ domain = expression.AND([[('package_id', '=', package_id.id)], domain])
+ if owner_id:
+ domain = expression.AND([[('owner_id', '=', owner_id.id)], domain])
+ domain = expression.AND([[('location_id', 'child_of', location_id.id)], domain])
+ else:
+ domain = expression.AND([['|', ('lot_id', '=', lot_id.id), ('lot_id', '=', False)] if lot_id else [('lot_id', '=', False)], domain])
+ domain = expression.AND([[('package_id', '=', package_id and package_id.id or False)], domain])
+ domain = expression.AND([[('owner_id', '=', owner_id and owner_id.id or False)], domain])
+ domain = expression.AND([[('location_id', '=', location_id.id)], domain])
+
+ # Copy code of _search for special NULLS FIRST/LAST order
+ self.check_access_rights('read')
+ query = self._where_calc(domain)
+ self._apply_ir_rules(query, 'read')
+ from_clause, where_clause, where_clause_params = query.get_sql()
+ where_str = where_clause and (" WHERE %s" % where_clause) or ''
+ query_str = 'SELECT "%s".id FROM ' % self._table + from_clause + where_str + " ORDER BY "+ removal_strategy_order
+ self._cr.execute(query_str, where_clause_params)
+ res = self._cr.fetchall()
+ # No uniquify list necessary as auto_join is not applied anyways...
+ quants = self.browse([x[0] for x in res])
+ quants = quants.sorted(lambda q: not q.lot_id)
+ return quants
+
+ @api.model
+ def _get_available_quantity(self, product_id, location_id, lot_id=None, package_id=None, owner_id=None, strict=False, allow_negative=False):
+ """ Return the available quantity, i.e. the sum of `quantity` minus the sum of
+ `reserved_quantity`, for the set of quants sharing the combination of `product_id,
+ location_id` if `strict` is set to False or sharing the *exact same characteristics*
+ otherwise.
+ This method is called in the following usecases:
+ - when a stock move checks its availability
+ - when a stock move actually assign
+ - when editing a move line, to check if the new value is forced or not
+ - when validating a move line with some forced values and have to potentially unlink an
+ equivalent move line in another picking
+ In the two first usecases, `strict` should be set to `False`, as we don't know what exact
+ quants we'll reserve, and the characteristics are meaningless in this context.
+ In the last ones, `strict` should be set to `True`, as we work on a specific set of
+ characteristics.
+
+ :return: available quantity as a float
+ """
+ self = self.sudo()
+ quants = self._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict)
+ rounding = product_id.uom_id.rounding
+ if product_id.tracking == 'none':
+ available_quantity = sum(quants.mapped('quantity')) - sum(quants.mapped('reserved_quantity'))
+ if allow_negative:
+ return available_quantity
+ else:
+ return available_quantity if float_compare(available_quantity, 0.0, precision_rounding=rounding) >= 0.0 else 0.0
+ else:
+ availaible_quantities = {lot_id: 0.0 for lot_id in list(set(quants.mapped('lot_id'))) + ['untracked']}
+ for quant in quants:
+ if not quant.lot_id:
+ availaible_quantities['untracked'] += quant.quantity - quant.reserved_quantity
+ else:
+ availaible_quantities[quant.lot_id] += quant.quantity - quant.reserved_quantity
+ if allow_negative:
+ return sum(availaible_quantities.values())
+ else:
+ return sum([available_quantity for available_quantity in availaible_quantities.values() if float_compare(available_quantity, 0, precision_rounding=rounding) > 0])
+
+ @api.onchange('location_id', 'product_id', 'lot_id', 'package_id', 'owner_id')
+ def _onchange_location_or_product_id(self):
+ vals = {}
+
+ # Once the new line is complete, fetch the new theoretical values.
+ if self.product_id and self.location_id:
+ # Sanity check if a lot has been set.
+ if self.lot_id:
+ if self.tracking == 'none' or self.product_id != self.lot_id.product_id:
+ vals['lot_id'] = None
+
+ quants = self._gather(self.product_id, self.location_id, lot_id=self.lot_id, package_id=self.package_id, owner_id=self.owner_id, strict=True)
+ reserved_quantity = sum(quants.mapped('reserved_quantity'))
+ quantity = sum(quants.mapped('quantity'))
+
+ vals['reserved_quantity'] = reserved_quantity
+ # Update `quantity` only if the user manually updated `inventory_quantity`.
+ if float_compare(self.quantity, self.inventory_quantity, precision_rounding=self.product_uom_id.rounding) == 0:
+ vals['quantity'] = quantity
+ # Special case: directly set the quantity to one for serial numbers,
+ # it'll trigger `inventory_quantity` compute.
+ if self.lot_id and self.tracking == 'serial':
+ vals['quantity'] = 1
+
+ if vals:
+ self.update(vals)
+
+ @api.onchange('inventory_quantity')
+ def _onchange_inventory_quantity(self):
+ if self.location_id and self.location_id.usage == 'inventory':
+ warning = {
+ 'title': _('You cannot modify inventory loss quantity'),
+ 'message': _(
+ 'Editing quantities in an Inventory Adjustment location is forbidden,'
+ 'those locations are used as counterpart when correcting the quantities.'
+ )
+ }
+ return {'warning': warning}
+
+ @api.model
+ def _update_available_quantity(self, product_id, location_id, quantity, lot_id=None, package_id=None, owner_id=None, in_date=None):
+ """ Increase or decrease `reserved_quantity` of a set of quants for a given set of
+ product_id/location_id/lot_id/package_id/owner_id.
+
+ :param product_id:
+ :param location_id:
+ :param quantity:
+ :param lot_id:
+ :param package_id:
+ :param owner_id:
+ :param datetime in_date: Should only be passed when calls to this method are done in
+ order to move a quant. When creating a tracked quant, the
+ current datetime will be used.
+ :return: tuple (available_quantity, in_date as a datetime)
+ """
+ self = self.sudo()
+ quants = self._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=True)
+ if lot_id and quantity > 0:
+ quants = quants.filtered(lambda q: q.lot_id)
+
+ incoming_dates = [d for d in quants.mapped('in_date') if d]
+ incoming_dates = [fields.Datetime.from_string(incoming_date) for incoming_date in incoming_dates]
+ if in_date:
+ incoming_dates += [in_date]
+ # If multiple incoming dates are available for a given lot_id/package_id/owner_id, we
+ # consider only the oldest one as being relevant.
+ if incoming_dates:
+ in_date = fields.Datetime.to_string(min(incoming_dates))
+ else:
+ in_date = fields.Datetime.now()
+
+ for quant in quants:
+ try:
+ with self._cr.savepoint(flush=False): # Avoid flush compute store of package
+ self._cr.execute("SELECT 1 FROM stock_quant WHERE id = %s FOR UPDATE NOWAIT", [quant.id], log_exceptions=False)
+ quant.write({
+ 'quantity': quant.quantity + quantity,
+ 'in_date': in_date,
+ })
+ break
+ except OperationalError as e:
+ if e.pgcode == '55P03': # could not obtain the lock
+ continue
+ else:
+ # Because savepoint doesn't flush, we need to invalidate the cache
+ # when there is a error raise from the write (other than lock-error)
+ self.clear_caches()
+ raise
+ else:
+ self.create({
+ 'product_id': product_id.id,
+ 'location_id': location_id.id,
+ 'quantity': quantity,
+ 'lot_id': lot_id and lot_id.id,
+ 'package_id': package_id and package_id.id,
+ 'owner_id': owner_id and owner_id.id,
+ 'in_date': in_date,
+ })
+ return self._get_available_quantity(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=False, allow_negative=True), fields.Datetime.from_string(in_date)
+
+ @api.model
+ def _update_reserved_quantity(self, product_id, location_id, quantity, lot_id=None, package_id=None, owner_id=None, strict=False):
+ """ Increase the reserved quantity, i.e. increase `reserved_quantity` for the set of quants
+ sharing the combination of `product_id, location_id` if `strict` is set to False or sharing
+ the *exact same characteristics* otherwise. Typically, this method is called when reserving
+ a move or updating a reserved move line. When reserving a chained move, the strict flag
+ should be enabled (to reserve exactly what was brought). When the move is MTS,it could take
+ anything from the stock, so we disable the flag. When editing a move line, we naturally
+ enable the flag, to reflect the reservation according to the edition.
+
+ :return: a list of tuples (quant, quantity_reserved) showing on which quant the reservation
+ was done and how much the system was able to reserve on it
+ """
+ self = self.sudo()
+ rounding = product_id.uom_id.rounding
+ quants = self._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict)
+ reserved_quants = []
+
+ if float_compare(quantity, 0, precision_rounding=rounding) > 0:
+ # if we want to reserve
+ available_quantity = sum(quants.filtered(lambda q: float_compare(q.quantity, 0, precision_rounding=rounding) > 0).mapped('quantity')) - sum(quants.mapped('reserved_quantity'))
+ if float_compare(quantity, available_quantity, precision_rounding=rounding) > 0:
+ raise UserError(_('It is not possible to reserve more products of %s than you have in stock.', product_id.display_name))
+ elif float_compare(quantity, 0, precision_rounding=rounding) < 0:
+ # if we want to unreserve
+ available_quantity = sum(quants.mapped('reserved_quantity'))
+ if float_compare(abs(quantity), available_quantity, precision_rounding=rounding) > 0:
+ raise UserError(_('It is not possible to unreserve more products of %s than you have in stock.', product_id.display_name))
+ else:
+ return reserved_quants
+
+ for quant in quants:
+ if float_compare(quantity, 0, precision_rounding=rounding) > 0:
+ max_quantity_on_quant = quant.quantity - quant.reserved_quantity
+ if float_compare(max_quantity_on_quant, 0, precision_rounding=rounding) <= 0:
+ continue
+ max_quantity_on_quant = min(max_quantity_on_quant, quantity)
+ quant.reserved_quantity += max_quantity_on_quant
+ reserved_quants.append((quant, max_quantity_on_quant))
+ quantity -= max_quantity_on_quant
+ available_quantity -= max_quantity_on_quant
+ else:
+ max_quantity_on_quant = min(quant.reserved_quantity, abs(quantity))
+ quant.reserved_quantity -= max_quantity_on_quant
+ reserved_quants.append((quant, -max_quantity_on_quant))
+ quantity += max_quantity_on_quant
+ available_quantity += max_quantity_on_quant
+
+ if float_is_zero(quantity, precision_rounding=rounding) or float_is_zero(available_quantity, precision_rounding=rounding):
+ break
+ return reserved_quants
+
+ @api.model
+ def _unlink_zero_quants(self):
+ """ _update_available_quantity may leave quants with no
+ quantity and no reserved_quantity. It used to directly unlink
+ these zero quants but this proved to hurt the performance as
+ this method is often called in batch and each unlink invalidate
+ the cache. We defer the calls to unlink in this method.
+ """
+ precision_digits = max(6, self.sudo().env.ref('product.decimal_product_uom').digits * 2)
+ # Use a select instead of ORM search for UoM robustness.
+ query = """SELECT id FROM stock_quant WHERE (round(quantity::numeric, %s) = 0 OR quantity IS NULL) AND round(reserved_quantity::numeric, %s) = 0;"""
+ params = (precision_digits, precision_digits)
+ self.env.cr.execute(query, params)
+ quant_ids = self.env['stock.quant'].browse([quant['id'] for quant in self.env.cr.dictfetchall()])
+ quant_ids.sudo().unlink()
+
+ @api.model
+ def _merge_quants(self):
+ """ In a situation where one transaction is updating a quant via
+ `_update_available_quantity` and another concurrent one calls this function with the same
+ argument, we’ll create a new quant in order for these transactions to not rollback. This
+ method will find and deduplicate these quants.
+ """
+ query = """WITH
+ dupes AS (
+ SELECT min(id) as to_update_quant_id,
+ (array_agg(id ORDER BY id))[2:array_length(array_agg(id), 1)] as to_delete_quant_ids,
+ SUM(reserved_quantity) as reserved_quantity,
+ SUM(quantity) as quantity
+ FROM stock_quant
+ GROUP BY product_id, company_id, location_id, lot_id, package_id, owner_id, in_date
+ HAVING count(id) > 1
+ ),
+ _up AS (
+ UPDATE stock_quant q
+ SET quantity = d.quantity,
+ reserved_quantity = d.reserved_quantity
+ FROM dupes d
+ WHERE d.to_update_quant_id = q.id
+ )
+ DELETE FROM stock_quant WHERE id in (SELECT unnest(to_delete_quant_ids) from dupes)
+ """
+ try:
+ with self.env.cr.savepoint():
+ self.env.cr.execute(query)
+ except Error as e:
+ _logger.info('an error occured while merging quants: %s', e.pgerror)
+
+ @api.model
+ def _quant_tasks(self):
+ self._merge_quants()
+ self._unlink_zero_quants()
+
+ @api.model
+ def _is_inventory_mode(self):
+ """ Used to control whether a quant was written on or created during an
+ "inventory session", meaning a mode where we need to create the stock.move
+ record necessary to be consistent with the `inventory_quantity` field.
+ """
+ return self.env.context.get('inventory_mode') is True and self.user_has_groups('stock.group_stock_manager')
+
+ @api.model
+ def _get_inventory_fields_create(self):
+ """ Returns a list of fields user can edit when he want to create a quant in `inventory_mode`.
+ """
+ return ['product_id', 'location_id', 'lot_id', 'package_id', 'owner_id', 'inventory_quantity']
+
+ @api.model
+ def _get_inventory_fields_write(self):
+ """ Returns a list of fields user can edit when he want to edit a quant in `inventory_mode`.
+ """
+ return ['inventory_quantity']
+
+ def _get_inventory_move_values(self, qty, location_id, location_dest_id, out=False):
+ """ Called when user manually set a new quantity (via `inventory_quantity`)
+ just before creating the corresponding stock move.
+
+ :param location_id: `stock.location`
+ :param location_dest_id: `stock.location`
+ :param out: boolean to set on True when the move go to inventory adjustment location.
+ :return: dict with all values needed to create a new `stock.move` with its move line.
+ """
+ self.ensure_one()
+ return {
+ 'name': _('Product Quantity Updated'),
+ 'product_id': self.product_id.id,
+ 'product_uom': self.product_uom_id.id,
+ 'product_uom_qty': qty,
+ 'company_id': self.company_id.id or self.env.company.id,
+ 'state': 'confirmed',
+ 'location_id': location_id.id,
+ 'location_dest_id': location_dest_id.id,
+ 'move_line_ids': [(0, 0, {
+ 'product_id': self.product_id.id,
+ 'product_uom_id': self.product_uom_id.id,
+ 'qty_done': qty,
+ 'location_id': location_id.id,
+ 'location_dest_id': location_dest_id.id,
+ 'company_id': self.company_id.id or self.env.company.id,
+ 'lot_id': self.lot_id.id,
+ 'package_id': out and self.package_id.id or False,
+ 'result_package_id': (not out) and self.package_id.id or False,
+ 'owner_id': self.owner_id.id,
+ })]
+ }
+
+ @api.model
+ def _get_quants_action(self, domain=None, extend=False):
+ """ Returns an action to open quant view.
+ Depending of the context (user have right to be inventory mode or not),
+ the list view will be editable or readonly.
+
+ :param domain: List for the domain, empty by default.
+ :param extend: If True, enables form, graph and pivot views. False by default.
+ """
+ self._quant_tasks()
+ ctx = dict(self.env.context or {})
+ ctx.pop('group_by', None)
+ action = {
+ 'name': _('Stock On Hand'),
+ 'view_type': 'tree',
+ 'view_mode': 'list,form',
+ 'res_model': 'stock.quant',
+ 'type': 'ir.actions.act_window',
+ 'context': ctx,
+ 'domain': domain or [],
+ 'help': """
+ <p class="o_view_nocontent_empty_folder">No Stock On Hand</p>
+ <p>This analysis gives you an overview of the current stock
+ level of your products.</p>
+ """
+ }
+
+ target_action = self.env.ref('stock.dashboard_open_quants', False)
+ if target_action:
+ action['id'] = target_action.id
+
+ if self._is_inventory_mode():
+ action['view_id'] = self.env.ref('stock.view_stock_quant_tree_editable').id
+ form_view = self.env.ref('stock.view_stock_quant_form_editable').id
+ else:
+ action['view_id'] = self.env.ref('stock.view_stock_quant_tree').id
+ form_view = self.env.ref('stock.view_stock_quant_form').id
+ action.update({
+ 'views': [
+ (action['view_id'], 'list'),
+ (form_view, 'form'),
+ ],
+ })
+ if extend:
+ action.update({
+ 'view_mode': 'tree,form,pivot,graph',
+ 'views': [
+ (action['view_id'], 'list'),
+ (form_view, 'form'),
+ (self.env.ref('stock.view_stock_quant_pivot').id, 'pivot'),
+ (self.env.ref('stock.stock_quant_view_graph').id, 'graph'),
+ ],
+ })
+ return action
+
+
+class QuantPackage(models.Model):
+ """ Packages containing quants and/or other packages """
+ _name = "stock.quant.package"
+ _description = "Packages"
+ _order = 'name'
+
+ name = fields.Char(
+ 'Package Reference', copy=False, index=True,
+ default=lambda self: self.env['ir.sequence'].next_by_code('stock.quant.package') or _('Unknown Pack'))
+ quant_ids = fields.One2many('stock.quant', 'package_id', 'Bulk Content', readonly=True,
+ domain=['|', ('quantity', '!=', 0), ('reserved_quantity', '!=', 0)])
+ packaging_id = fields.Many2one(
+ 'product.packaging', 'Package Type', index=True, check_company=True)
+ location_id = fields.Many2one(
+ 'stock.location', 'Location', compute='_compute_package_info',
+ index=True, readonly=True, store=True)
+ company_id = fields.Many2one(
+ 'res.company', 'Company', compute='_compute_package_info',
+ index=True, readonly=True, store=True)
+ owner_id = fields.Many2one(
+ 'res.partner', 'Owner', compute='_compute_package_info', search='_search_owner',
+ index=True, readonly=True, compute_sudo=True)
+
+ @api.depends('quant_ids.package_id', 'quant_ids.location_id', 'quant_ids.company_id', 'quant_ids.owner_id', 'quant_ids.quantity', 'quant_ids.reserved_quantity')
+ def _compute_package_info(self):
+ for package in self:
+ values = {'location_id': False, 'owner_id': False}
+ if package.quant_ids:
+ values['location_id'] = package.quant_ids[0].location_id
+ if all(q.owner_id == package.quant_ids[0].owner_id for q in package.quant_ids):
+ values['owner_id'] = package.quant_ids[0].owner_id
+ if all(q.company_id == package.quant_ids[0].company_id for q in package.quant_ids):
+ values['company_id'] = package.quant_ids[0].company_id
+ package.location_id = values['location_id']
+ package.company_id = values.get('company_id')
+ package.owner_id = values['owner_id']
+
+ def name_get(self):
+ return list(self._compute_complete_name().items())
+
+ def _compute_complete_name(self):
+ """ Forms complete name of location from parent location to child location. """
+ res = {}
+ for package in self:
+ name = package.name
+ res[package.id] = name
+ return res
+
+ def _search_owner(self, operator, value):
+ if value:
+ packs = self.search([('quant_ids.owner_id', operator, value)])
+ else:
+ packs = self.search([('quant_ids', operator, value)])
+ if packs:
+ return [('id', 'parent_of', packs.ids)]
+ else:
+ return [('id', '=', False)]
+
+ def unpack(self):
+ for package in self:
+ move_line_to_modify = self.env['stock.move.line'].search([
+ ('package_id', '=', package.id),
+ ('state', 'in', ('assigned', 'partially_available')),
+ ('product_qty', '!=', 0),
+ ])
+ move_line_to_modify.write({'package_id': False})
+ package.mapped('quant_ids').sudo().write({'package_id': False})
+
+ # Quant clean-up, mostly to avoid multiple quants of the same product. For example, unpack
+ # 2 packages of 50, then reserve 100 => a quant of -50 is created at transfer validation.
+ self.env['stock.quant']._merge_quants()
+ self.env['stock.quant']._unlink_zero_quants()
+
+ def action_view_picking(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("stock.action_picking_tree_all")
+ domain = ['|', ('result_package_id', 'in', self.ids), ('package_id', 'in', self.ids)]
+ pickings = self.env['stock.move.line'].search(domain).mapped('picking_id')
+ action['domain'] = [('id', 'in', pickings.ids)]
+ return action
+
+ def _get_contained_quants(self):
+ return self.env['stock.quant'].search([('package_id', 'in', self.ids)])
+
+ def _allowed_to_move_between_transfers(self):
+ return True
diff --git a/addons/stock/models/stock_rule.py b/addons/stock/models/stock_rule.py
new file mode 100644
index 00000000..b8eee2b8
--- /dev/null
+++ b/addons/stock/models/stock_rule.py
@@ -0,0 +1,560 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+from collections import defaultdict, namedtuple
+
+from dateutil.relativedelta import relativedelta
+
+from odoo import SUPERUSER_ID, _, api, fields, models, registry
+from odoo.exceptions import UserError
+from odoo.osv import expression
+from odoo.tools import float_compare, float_is_zero, html_escape
+from odoo.tools.misc import split_every
+
+_logger = logging.getLogger(__name__)
+
+
+class ProcurementException(Exception):
+ """An exception raised by ProcurementGroup `run` containing all the faulty
+ procurements.
+ """
+ def __init__(self, procurement_exceptions):
+ """:param procurement_exceptions: a list of tuples containing the faulty
+ procurement and their error messages
+ :type procurement_exceptions: list
+ """
+ self.procurement_exceptions = procurement_exceptions
+
+
+class StockRule(models.Model):
+ """ A rule describe what a procurement should do; produce, buy, move, ... """
+ _name = 'stock.rule'
+ _description = "Stock Rule"
+ _order = "sequence, id"
+ _check_company_auto = True
+
+ @api.model
+ def default_get(self, fields_list):
+ res = super().default_get(fields_list)
+ if 'company_id' in fields_list and not res['company_id']:
+ res['company_id'] = self.env.company.id
+ return res
+
+ name = fields.Char(
+ 'Name', required=True, translate=True,
+ help="This field will fill the packing origin and the name of its moves")
+ active = fields.Boolean(
+ 'Active', default=True,
+ help="If unchecked, it will allow you to hide the rule without removing it.")
+ group_propagation_option = fields.Selection([
+ ('none', 'Leave Empty'),
+ ('propagate', 'Propagate'),
+ ('fixed', 'Fixed')], string="Propagation of Procurement Group", default='propagate')
+ group_id = fields.Many2one('procurement.group', 'Fixed Procurement Group')
+ action = fields.Selection(
+ selection=[('pull', 'Pull From'), ('push', 'Push To'), ('pull_push', 'Pull & Push')], string='Action',
+ required=True)
+ sequence = fields.Integer('Sequence', default=20)
+ company_id = fields.Many2one('res.company', 'Company',
+ default=lambda self: self.env.company,
+ domain="[('id', '=?', route_company_id)]")
+ location_id = fields.Many2one('stock.location', 'Destination Location', required=True, check_company=True)
+ location_src_id = fields.Many2one('stock.location', 'Source Location', check_company=True)
+ route_id = fields.Many2one('stock.location.route', 'Route', required=True, ondelete='cascade')
+ route_company_id = fields.Many2one(related='route_id.company_id', string='Route Company')
+ procure_method = fields.Selection([
+ ('make_to_stock', 'Take From Stock'),
+ ('make_to_order', 'Trigger Another Rule'),
+ ('mts_else_mto', 'Take From Stock, if unavailable, Trigger Another Rule')], string='Supply Method', default='make_to_stock', required=True,
+ help="Take From Stock: the products will be taken from the available stock of the source location.\n"
+ "Trigger Another Rule: the system will try to find a stock rule to bring the products in the source location. The available stock will be ignored.\n"
+ "Take From Stock, if Unavailable, Trigger Another Rule: the products will be taken from the available stock of the source location."
+ "If there is no stock available, the system will try to find a rule to bring the products in the source location.")
+ route_sequence = fields.Integer('Route Sequence', related='route_id.sequence', store=True, readonly=False, compute_sudo=True)
+ picking_type_id = fields.Many2one(
+ 'stock.picking.type', 'Operation Type',
+ required=True, check_company=True,
+ domain="[('code', '=?', picking_type_code_domain)]")
+ picking_type_code_domain = fields.Char(compute='_compute_picking_type_code_domain')
+ delay = fields.Integer('Lead Time', default=0, help="The expected date of the created transfer will be computed based on this lead time.")
+ partner_address_id = fields.Many2one(
+ 'res.partner', 'Partner Address',
+ check_company=True,
+ help="Address where goods should be delivered. Optional.")
+ propagate_cancel = fields.Boolean(
+ 'Cancel Next Move', default=False,
+ help="When ticked, if the move created by this rule is cancelled, the next move will be cancelled too.")
+ warehouse_id = fields.Many2one('stock.warehouse', 'Warehouse', check_company=True)
+ propagate_warehouse_id = fields.Many2one(
+ 'stock.warehouse', 'Warehouse to Propagate',
+ help="The warehouse to propagate on the created move/procurement, which can be different of the warehouse this rule is for (e.g for resupplying rules from another warehouse)")
+ auto = fields.Selection([
+ ('manual', 'Manual Operation'),
+ ('transparent', 'Automatic No Step Added')], string='Automatic Move',
+ default='manual', index=True, required=True,
+ help="The 'Manual Operation' value will create a stock move after the current one. "
+ "With 'Automatic No Step Added', the location is replaced in the original move.")
+ rule_message = fields.Html(compute='_compute_action_message')
+
+ @api.onchange('picking_type_id')
+ def _onchange_picking_type(self):
+ """ Modify locations to the default picking type's locations source and
+ destination.
+ Enable the delay alert if the picking type is a delivery
+ """
+ self.location_src_id = self.picking_type_id.default_location_src_id.id
+ self.location_id = self.picking_type_id.default_location_dest_id.id
+
+ @api.onchange('route_id', 'company_id')
+ def _onchange_route(self):
+ """ Ensure that the rule's company is the same than the route's company. """
+ if self.route_id.company_id:
+ self.company_id = self.route_id.company_id
+ if self.picking_type_id.warehouse_id.company_id != self.route_id.company_id:
+ self.picking_type_id = False
+
+ def _get_message_values(self):
+ """ Return the source, destination and picking_type applied on a stock
+ rule. The purpose of this function is to avoid code duplication in
+ _get_message_dict functions since it often requires those data.
+ """
+ source = self.location_src_id and self.location_src_id.display_name or _('Source Location')
+ destination = self.location_id and self.location_id.display_name or _('Destination Location')
+ operation = self.picking_type_id and self.picking_type_id.name or _('Operation Type')
+ return source, destination, operation
+
+ def _get_message_dict(self):
+ """ Return a dict with the different possible message used for the
+ rule message. It should return one message for each stock.rule action
+ (except push and pull). This function is override in mrp and
+ purchase_stock in order to complete the dictionary.
+ """
+ message_dict = {}
+ source, destination, operation = self._get_message_values()
+ if self.action in ('push', 'pull', 'pull_push'):
+ suffix = ""
+ if self.procure_method == 'make_to_order' and self.location_src_id:
+ suffix = _("<br>A need is created in <b>%s</b> and a rule will be triggered to fulfill it.", source)
+ if self.procure_method == 'mts_else_mto' and self.location_src_id:
+ suffix = _("<br>If the products are not available in <b>%s</b>, a rule will be triggered to bring products in this location.", source)
+ message_dict = {
+ 'pull': _('When products are needed in <b>%s</b>, <br/> <b>%s</b> are created from <b>%s</b> to fulfill the need.', destination, operation, source) + suffix,
+ 'push': _('When products arrive in <b>%s</b>, <br/> <b>%s</b> are created to send them in <b>%s</b>.', source, operation, destination)
+ }
+ return message_dict
+
+ @api.depends('action', 'location_id', 'location_src_id', 'picking_type_id', 'procure_method')
+ def _compute_action_message(self):
+ """ Generate dynamicaly a message that describe the rule purpose to the
+ end user.
+ """
+ action_rules = self.filtered(lambda rule: rule.action)
+ for rule in action_rules:
+ message_dict = rule._get_message_dict()
+ message = message_dict.get(rule.action) and message_dict[rule.action] or ""
+ if rule.action == 'pull_push':
+ message = message_dict['pull'] + "<br/><br/>" + message_dict['push']
+ rule.rule_message = message
+ (self - action_rules).rule_message = None
+
+ @api.depends('action')
+ def _compute_picking_type_code_domain(self):
+ self.picking_type_code_domain = False
+
+ def _run_push(self, move):
+ """ Apply a push rule on a move.
+ If the rule is 'no step added' it will modify the destination location
+ on the move.
+ If the rule is 'manual operation' it will generate a new move in order
+ to complete the section define by the rule.
+ Care this function is not call by method run. It is called explicitely
+ in stock_move.py inside the method _push_apply
+ """
+ new_date = fields.Datetime.to_string(move.date + relativedelta(days=self.delay))
+ if self.auto == 'transparent':
+ old_dest_location = move.location_dest_id
+ move.write({'date': new_date, 'location_dest_id': self.location_id.id})
+ # make sure the location_dest_id is consistent with the move line location dest
+ if move.move_line_ids:
+ move.move_line_ids.location_dest_id = move.location_dest_id._get_putaway_strategy(move.product_id) or move.location_dest_id
+
+ # avoid looping if a push rule is not well configured; otherwise call again push_apply to see if a next step is defined
+ if self.location_id != old_dest_location:
+ # TDE FIXME: should probably be done in the move model IMO
+ move._push_apply()
+ else:
+ new_move_vals = self._push_prepare_move_copy_values(move, new_date)
+ new_move = move.sudo().copy(new_move_vals)
+ if new_move._should_bypass_reservation():
+ new_move.write({'procure_method': 'make_to_stock'})
+ if not new_move.location_id.should_bypass_reservation():
+ move.write({'move_dest_ids': [(4, new_move.id)]})
+ new_move._action_confirm()
+
+ def _push_prepare_move_copy_values(self, move_to_copy, new_date):
+ company_id = self.company_id.id
+ if not company_id:
+ company_id = self.sudo().warehouse_id and self.sudo().warehouse_id.company_id.id or self.sudo().picking_type_id.warehouse_id.company_id.id
+ new_move_vals = {
+ 'origin': move_to_copy.origin or move_to_copy.picking_id.name or "/",
+ 'location_id': move_to_copy.location_dest_id.id,
+ 'location_dest_id': self.location_id.id,
+ 'date': new_date,
+ 'company_id': company_id,
+ 'picking_id': False,
+ 'picking_type_id': self.picking_type_id.id,
+ 'propagate_cancel': self.propagate_cancel,
+ 'warehouse_id': self.warehouse_id.id,
+ 'procure_method': 'make_to_order',
+ }
+ return new_move_vals
+
+ @api.model
+ def _run_pull(self, procurements):
+ moves_values_by_company = defaultdict(list)
+ mtso_products_by_locations = defaultdict(list)
+
+ # To handle the `mts_else_mto` procure method, we do a preliminary loop to
+ # isolate the products we would need to read the forecasted quantity,
+ # in order to to batch the read. We also make a sanitary check on the
+ # `location_src_id` field.
+ for procurement, rule in procurements:
+ if not rule.location_src_id:
+ msg = _('No source location defined on stock rule: %s!') % (rule.name, )
+ raise ProcurementException([(procurement, msg)])
+
+ if rule.procure_method == 'mts_else_mto':
+ mtso_products_by_locations[rule.location_src_id].append(procurement.product_id.id)
+
+ # Get the forecasted quantity for the `mts_else_mto` procurement.
+ forecasted_qties_by_loc = {}
+ for location, product_ids in mtso_products_by_locations.items():
+ products = self.env['product.product'].browse(product_ids).with_context(location=location.id)
+ forecasted_qties_by_loc[location] = {product.id: product.free_qty for product in products}
+
+ # Prepare the move values, adapt the `procure_method` if needed.
+ for procurement, rule in procurements:
+ procure_method = rule.procure_method
+ if rule.procure_method == 'mts_else_mto':
+ qty_needed = procurement.product_uom._compute_quantity(procurement.product_qty, procurement.product_id.uom_id)
+ qty_available = forecasted_qties_by_loc[rule.location_src_id][procurement.product_id.id]
+ if float_compare(qty_needed, qty_available, precision_rounding=procurement.product_id.uom_id.rounding) <= 0:
+ procure_method = 'make_to_stock'
+ forecasted_qties_by_loc[rule.location_src_id][procurement.product_id.id] -= qty_needed
+ else:
+ procure_method = 'make_to_order'
+
+ move_values = rule._get_stock_move_values(*procurement)
+ move_values['procure_method'] = procure_method
+ moves_values_by_company[procurement.company_id.id].append(move_values)
+
+ for company_id, moves_values in moves_values_by_company.items():
+ # create the move as SUPERUSER because the current user may not have the rights to do it (mto product launched by a sale for example)
+ moves = self.env['stock.move'].with_user(SUPERUSER_ID).sudo().with_company(company_id).create(moves_values)
+ # Since action_confirm launch following procurement_group we should activate it.
+ moves._action_confirm()
+ return True
+
+ def _get_custom_move_fields(self):
+ """ The purpose of this method is to be override in order to easily add
+ fields from procurement 'values' argument to move data.
+ """
+ return []
+
+ def _get_stock_move_values(self, product_id, product_qty, product_uom, location_id, name, origin, company_id, values):
+ ''' Returns a dictionary of values that will be used to create a stock move from a procurement.
+ This function assumes that the given procurement has a rule (action == 'pull' or 'pull_push') set on it.
+
+ :param procurement: browse record
+ :rtype: dictionary
+ '''
+ group_id = False
+ if self.group_propagation_option == 'propagate':
+ group_id = values.get('group_id', False) and values['group_id'].id
+ elif self.group_propagation_option == 'fixed':
+ group_id = self.group_id.id
+
+ date_scheduled = fields.Datetime.to_string(
+ fields.Datetime.from_string(values['date_planned']) - relativedelta(days=self.delay or 0)
+ )
+ date_deadline = values.get('date_deadline') and (fields.Datetime.to_datetime(values['date_deadline']) - relativedelta(days=self.delay or 0)) or False
+ partner = self.partner_address_id or (values.get('group_id', False) and values['group_id'].partner_id)
+ if partner:
+ product_id = product_id.with_context(lang=partner.lang or self.env.user.lang)
+ picking_description = product_id._get_description(self.picking_type_id)
+ if values.get('product_description_variants'):
+ picking_description += values['product_description_variants']
+ # it is possible that we've already got some move done, so check for the done qty and create
+ # a new move with the correct qty
+ qty_left = product_qty
+
+ move_dest_ids = []
+ if not self.location_id.should_bypass_reservation():
+ move_dest_ids = values.get('move_dest_ids', False) and [(4, x.id) for x in values['move_dest_ids']] or []
+
+ move_values = {
+ 'name': name[:2000],
+ 'company_id': self.company_id.id or self.location_src_id.company_id.id or self.location_id.company_id.id or company_id.id,
+ 'product_id': product_id.id,
+ 'product_uom': product_uom.id,
+ 'product_uom_qty': qty_left,
+ 'partner_id': partner.id if partner else False,
+ 'location_id': self.location_src_id.id,
+ 'location_dest_id': location_id.id,
+ 'move_dest_ids': move_dest_ids,
+ 'rule_id': self.id,
+ 'procure_method': self.procure_method,
+ 'origin': origin,
+ 'picking_type_id': self.picking_type_id.id,
+ 'group_id': group_id,
+ 'route_ids': [(4, route.id) for route in values.get('route_ids', [])],
+ 'warehouse_id': self.propagate_warehouse_id.id or self.warehouse_id.id,
+ 'date': date_scheduled,
+ 'date_deadline': False if self.group_propagation_option == 'fixed' else date_deadline,
+ 'propagate_cancel': self.propagate_cancel,
+ 'description_picking': picking_description,
+ 'priority': values.get('priority', "0"),
+ 'orderpoint_id': values.get('orderpoint_id') and values['orderpoint_id'].id,
+ }
+ for field in self._get_custom_move_fields():
+ if field in values:
+ move_values[field] = values.get(field)
+ return move_values
+
+ def _get_lead_days(self, product):
+ """Returns the cumulative delay and its description encountered by a
+ procurement going through the rules in `self`.
+
+ :param product: the product of the procurement
+ :type product: :class:`~odoo.addons.product.models.product.ProductProduct`
+ :return: the cumulative delay and cumulative delay's description
+ :rtype: tuple
+ """
+ delay = sum(self.filtered(lambda r: r.action in ['pull', 'pull_push']).mapped('delay'))
+ if self.env.context.get('bypass_delay_description'):
+ delay_description = ""
+ else:
+ delay_description = ''.join(['<tr><td>%s %s</td><td class="text-right">+ %d %s</td></tr>' % (_('Delay on'), html_escape(rule.name), rule.delay, _('day(s)')) for rule in self if rule.action in ['pull', 'pull_push'] and rule.delay])
+ return delay, delay_description
+
+
+class ProcurementGroup(models.Model):
+ """
+ The procurement group class is used to group products together
+ when computing procurements. (tasks, physical products, ...)
+
+ The goal is that when you have one sales order of several products
+ and the products are pulled from the same or several location(s), to keep
+ having the moves grouped into pickings that represent the sales order.
+
+ Used in: sales order (to group delivery order lines like the so), pull/push
+ rules (to pack like the delivery order), on orderpoints (e.g. for wave picking
+ all the similar products together).
+
+ Grouping is made only if the source and the destination is the same.
+ Suppose you have 4 lines on a picking from Output where 2 lines will need
+ to come from Input (crossdock) and 2 lines coming from Stock -> Output As
+ the four will have the same group ids from the SO, the move from input will
+ have a stock.picking with 2 grouped lines and the move from stock will have
+ 2 grouped lines also.
+
+ The name is usually the name of the original document (sales order) or a
+ sequence computed if created manually.
+ """
+ _name = 'procurement.group'
+ _description = 'Procurement Group'
+ _order = "id desc"
+
+ Procurement = namedtuple('Procurement', ['product_id', 'product_qty',
+ 'product_uom', 'location_id', 'name', 'origin', 'company_id', 'values'])
+ partner_id = fields.Many2one('res.partner', 'Partner')
+ name = fields.Char(
+ 'Reference',
+ default=lambda self: self.env['ir.sequence'].next_by_code('procurement.group') or '',
+ required=True)
+ move_type = fields.Selection([
+ ('direct', 'Partial'),
+ ('one', 'All at once')], string='Delivery Type', default='direct',
+ required=True)
+ stock_move_ids = fields.One2many('stock.move', 'group_id', string="Related Stock Moves")
+
+ @api.model
+ def run(self, procurements, raise_user_error=True):
+ """Fulfil `procurements` with the help of stock rules.
+
+ Procurements are needs of products at a certain location. To fulfil
+ these needs, we need to create some sort of documents (`stock.move`
+ by default, but extensions of `_run_` methods allow to create every
+ type of documents).
+
+ :param procurements: the description of the procurement
+ :type list: list of `~odoo.addons.stock.models.stock_rule.ProcurementGroup.Procurement`
+ :param raise_user_error: will raise either an UserError or a ProcurementException
+ :type raise_user_error: boolan, optional
+ :raises UserError: if `raise_user_error` is True and a procurement isn't fulfillable
+ :raises ProcurementException: if `raise_user_error` is False and a procurement isn't fulfillable
+ """
+
+ def raise_exception(procurement_errors):
+ if raise_user_error:
+ dummy, errors = zip(*procurement_errors)
+ raise UserError('\n'.join(errors))
+ else:
+ raise ProcurementException(procurement_errors)
+
+ actions_to_run = defaultdict(list)
+ procurement_errors = []
+ for procurement in procurements:
+ procurement.values.setdefault('company_id', procurement.location_id.company_id)
+ procurement.values.setdefault('priority', '0')
+ procurement.values.setdefault('date_planned', fields.Datetime.now())
+ if (
+ procurement.product_id.type not in ('consu', 'product') or
+ float_is_zero(procurement.product_qty, precision_rounding=procurement.product_uom.rounding)
+ ):
+ continue
+ rule = self._get_rule(procurement.product_id, procurement.location_id, procurement.values)
+ if not rule:
+ error = _('No rule has been found to replenish "%s" in "%s".\nVerify the routes configuration on the product.') %\
+ (procurement.product_id.display_name, procurement.location_id.display_name)
+ procurement_errors.append((procurement, error))
+ else:
+ action = 'pull' if rule.action == 'pull_push' else rule.action
+ actions_to_run[action].append((procurement, rule))
+
+ if procurement_errors:
+ raise_exception(procurement_errors)
+
+ for action, procurements in actions_to_run.items():
+ if hasattr(self.env['stock.rule'], '_run_%s' % action):
+ try:
+ getattr(self.env['stock.rule'], '_run_%s' % action)(procurements)
+ except ProcurementException as e:
+ procurement_errors += e.procurement_exceptions
+ else:
+ _logger.error("The method _run_%s doesn't exist on the procurement rules" % action)
+
+ if procurement_errors:
+ raise_exception(procurement_errors)
+ return True
+
+ @api.model
+ def _search_rule(self, route_ids, product_id, warehouse_id, domain):
+ """ First find a rule among the ones defined on the procurement
+ group, then try on the routes defined for the product, finally fallback
+ on the default behavior
+ """
+ if warehouse_id:
+ domain = expression.AND([['|', ('warehouse_id', '=', warehouse_id.id), ('warehouse_id', '=', False)], domain])
+ Rule = self.env['stock.rule']
+ res = self.env['stock.rule']
+ if route_ids:
+ res = Rule.search(expression.AND([[('route_id', 'in', route_ids.ids)], domain]), order='route_sequence, sequence', limit=1)
+ if not res:
+ product_routes = product_id.route_ids | product_id.categ_id.total_route_ids
+ if product_routes:
+ res = Rule.search(expression.AND([[('route_id', 'in', product_routes.ids)], domain]), order='route_sequence, sequence', limit=1)
+ if not res and warehouse_id:
+ warehouse_routes = warehouse_id.route_ids
+ if warehouse_routes:
+ res = Rule.search(expression.AND([[('route_id', 'in', warehouse_routes.ids)], domain]), order='route_sequence, sequence', limit=1)
+ return res
+
+ @api.model
+ def _get_rule(self, product_id, location_id, values):
+ """ Find a pull rule for the location_id, fallback on the parent
+ locations if it could not be found.
+ """
+ result = False
+ location = location_id
+ while (not result) and location:
+ domain = self._get_rule_domain(location, values)
+ result = self._search_rule(values.get('route_ids', False), product_id, values.get('warehouse_id', False), domain)
+ location = location.location_id
+ return result
+
+ @api.model
+ def _get_rule_domain(self, location, values):
+ domain = ['&', ('location_id', '=', location.id), ('action', '!=', 'push')]
+ # In case the method is called by the superuser, we need to restrict the rules to the
+ # ones of the company. This is not useful as a regular user since there is a record
+ # rule to filter out the rules based on the company.
+ if self.env.su and values.get('company_id'):
+ domain_company = ['|', ('company_id', '=', False), ('company_id', 'child_of', values['company_id'].ids)]
+ domain = expression.AND([domain, domain_company])
+ return domain
+
+ def _merge_domain(self, values, rule, group_id):
+ return [
+ ('group_id', '=', group_id), # extra logic?
+ ('location_id', '=', rule.location_src_id.id),
+ ('location_dest_id', '=', values['location_id'].id),
+ ('picking_type_id', '=', rule.picking_type_id.id),
+ ('picking_id.printed', '=', False),
+ ('picking_id.state', 'in', ['draft', 'confirmed', 'waiting', 'assigned']),
+ ('picking_id.backorder_id', '=', False),
+ ('product_id', '=', values['product_id'].id)]
+
+ @api.model
+ def _get_moves_to_assign_domain(self, company_id):
+ moves_domain = [
+ ('state', 'in', ['confirmed', 'partially_available']),
+ ('product_uom_qty', '!=', 0.0)
+ ]
+ if company_id:
+ moves_domain = expression.AND([[('company_id', '=', company_id)], moves_domain])
+ return moves_domain
+
+ @api.model
+ def _run_scheduler_tasks(self, use_new_cursor=False, company_id=False):
+ # Minimum stock rules
+ domain = self._get_orderpoint_domain(company_id=company_id)
+ orderpoints = self.env['stock.warehouse.orderpoint'].search(domain)
+ # ensure that qty_* which depends on datetime.now() are correctly
+ # recomputed
+ orderpoints.sudo()._compute_qty_to_order()
+ orderpoints.sudo()._procure_orderpoint_confirm(use_new_cursor=use_new_cursor, company_id=company_id, raise_user_error=False)
+ if use_new_cursor:
+ self._cr.commit()
+
+ # Search all confirmed stock_moves and try to assign them
+ domain = self._get_moves_to_assign_domain(company_id)
+ moves_to_assign = self.env['stock.move'].search(domain, limit=None,
+ order='priority desc, date asc')
+ for moves_chunk in split_every(100, moves_to_assign.ids):
+ self.env['stock.move'].browse(moves_chunk).sudo()._action_assign()
+ if use_new_cursor:
+ self._cr.commit()
+
+ # Merge duplicated quants
+ self.env['stock.quant']._quant_tasks()
+
+ if use_new_cursor:
+ self._cr.commit()
+
+ @api.model
+ def run_scheduler(self, use_new_cursor=False, company_id=False):
+ """ Call the scheduler in order to check the running procurements (super method), to check the minimum stock rules
+ and the availability of moves. This function is intended to be run for all the companies at the same time, so
+ we run functions as SUPERUSER to avoid intercompanies and access rights issues. """
+ try:
+ if use_new_cursor:
+ cr = registry(self._cr.dbname).cursor()
+ self = self.with_env(self.env(cr=cr)) # TDE FIXME
+
+ self._run_scheduler_tasks(use_new_cursor=use_new_cursor, company_id=company_id)
+ finally:
+ if use_new_cursor:
+ try:
+ self._cr.close()
+ except Exception:
+ pass
+ return {}
+
+ @api.model
+ def _get_orderpoint_domain(self, company_id=False):
+ domain = [('trigger', '=', 'auto'), ('product_id.active', '=', True)]
+ if company_id:
+ domain += [('company_id', '=', company_id)]
+ return domain
diff --git a/addons/stock/models/stock_scrap.py b/addons/stock/models/stock_scrap.py
new file mode 100644
index 00000000..bc915cd3
--- /dev/null
+++ b/addons/stock/models/stock_scrap.py
@@ -0,0 +1,181 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+from odoo.tools import float_compare
+
+
+class StockScrap(models.Model):
+ _name = 'stock.scrap'
+ _inherit = ['mail.thread']
+ _order = 'id desc'
+ _description = 'Scrap'
+
+ def _get_default_scrap_location_id(self):
+ company_id = self.env.context.get('default_company_id') or self.env.company.id
+ return self.env['stock.location'].search([('scrap_location', '=', True), ('company_id', 'in', [company_id, False])], limit=1).id
+
+ def _get_default_location_id(self):
+ company_id = self.env.context.get('default_company_id') or self.env.company.id
+ warehouse = self.env['stock.warehouse'].search([('company_id', '=', company_id)], limit=1)
+ if warehouse:
+ return warehouse.lot_stock_id.id
+ return None
+
+ name = fields.Char(
+ 'Reference', default=lambda self: _('New'),
+ copy=False, readonly=True, required=True,
+ states={'done': [('readonly', True)]})
+ company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company, required=True, states={'done': [('readonly', True)]})
+ origin = fields.Char(string='Source Document')
+ product_id = fields.Many2one(
+ 'product.product', 'Product', domain="[('type', 'in', ['product', 'consu']), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
+ required=True, states={'done': [('readonly', True)]}, check_company=True)
+ product_uom_id = fields.Many2one(
+ 'uom.uom', 'Unit of Measure',
+ required=True, states={'done': [('readonly', True)]}, domain="[('category_id', '=', product_uom_category_id)]")
+ product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id')
+ tracking = fields.Selection(string='Product Tracking', readonly=True, related="product_id.tracking")
+ lot_id = fields.Many2one(
+ 'stock.production.lot', 'Lot/Serial',
+ states={'done': [('readonly', True)]}, domain="[('product_id', '=', product_id), ('company_id', '=', company_id)]", check_company=True)
+ package_id = fields.Many2one(
+ 'stock.quant.package', 'Package',
+ states={'done': [('readonly', True)]}, check_company=True)
+ owner_id = fields.Many2one('res.partner', 'Owner', states={'done': [('readonly', True)]}, check_company=True)
+ move_id = fields.Many2one('stock.move', 'Scrap Move', readonly=True, check_company=True, copy=False)
+ picking_id = fields.Many2one('stock.picking', 'Picking', states={'done': [('readonly', True)]}, check_company=True)
+ location_id = fields.Many2one(
+ 'stock.location', 'Source Location', domain="[('usage', '=', 'internal'), ('company_id', 'in', [company_id, False])]",
+ required=True, states={'done': [('readonly', True)]}, default=_get_default_location_id, check_company=True)
+ scrap_location_id = fields.Many2one(
+ 'stock.location', 'Scrap Location', default=_get_default_scrap_location_id,
+ domain="[('scrap_location', '=', True), ('company_id', 'in', [company_id, False])]", required=True, states={'done': [('readonly', True)]}, check_company=True)
+ scrap_qty = fields.Float('Quantity', default=1.0, required=True, states={'done': [('readonly', True)]})
+ state = fields.Selection([
+ ('draft', 'Draft'),
+ ('done', 'Done')],
+ string='Status', default="draft", readonly=True, tracking=True)
+ date_done = fields.Datetime('Date', readonly=True)
+
+ @api.onchange('picking_id')
+ def _onchange_picking_id(self):
+ if self.picking_id:
+ self.location_id = (self.picking_id.state == 'done') and self.picking_id.location_dest_id.id or self.picking_id.location_id.id
+
+ @api.onchange('product_id')
+ def _onchange_product_id(self):
+ if self.product_id:
+ if self.tracking == 'serial':
+ self.scrap_qty = 1
+ self.product_uom_id = self.product_id.uom_id.id
+ # Check if we can get a more precise location instead of
+ # the default location (a location corresponding to where the
+ # reserved product is stored)
+ if self.picking_id:
+ for move_line in self.picking_id.move_line_ids:
+ if move_line.product_id == self.product_id:
+ self.location_id = move_line.location_id if move_line.state != 'done' else move_line.location_dest_id
+ break
+
+ @api.onchange('company_id')
+ def _onchange_company_id(self):
+ if self.company_id:
+ warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.company_id.id)], limit=1)
+ # Change the locations only if their company doesn't match the company set, otherwise
+ # user defaults are overridden.
+ if self.location_id.company_id != self.company_id:
+ self.location_id = warehouse.lot_stock_id
+ if self.scrap_location_id.company_id != self.company_id:
+ self.scrap_location_id = self.env['stock.location'].search([
+ ('scrap_location', '=', True),
+ ('company_id', 'in', [self.company_id.id, False]),
+ ], limit=1)
+ else:
+ self.location_id = False
+ self.scrap_location_id = False
+
+ def unlink(self):
+ if 'done' in self.mapped('state'):
+ raise UserError(_('You cannot delete a scrap which is done.'))
+ return super(StockScrap, self).unlink()
+
+ def _prepare_move_values(self):
+ self.ensure_one()
+ return {
+ 'name': self.name,
+ 'origin': self.origin or self.picking_id.name or self.name,
+ 'company_id': self.company_id.id,
+ 'product_id': self.product_id.id,
+ 'product_uom': self.product_uom_id.id,
+ 'state': 'draft',
+ 'product_uom_qty': self.scrap_qty,
+ 'location_id': self.location_id.id,
+ 'scrapped': True,
+ 'location_dest_id': self.scrap_location_id.id,
+ 'move_line_ids': [(0, 0, {'product_id': self.product_id.id,
+ 'product_uom_id': self.product_uom_id.id,
+ 'qty_done': self.scrap_qty,
+ 'location_id': self.location_id.id,
+ 'location_dest_id': self.scrap_location_id.id,
+ 'package_id': self.package_id.id,
+ 'owner_id': self.owner_id.id,
+ 'lot_id': self.lot_id.id, })],
+# 'restrict_partner_id': self.owner_id.id,
+ 'picking_id': self.picking_id.id
+ }
+
+ def do_scrap(self):
+ self._check_company()
+ for scrap in self:
+ scrap.name = self.env['ir.sequence'].next_by_code('stock.scrap') or _('New')
+ move = self.env['stock.move'].create(scrap._prepare_move_values())
+ # master: replace context by cancel_backorder
+ move.with_context(is_scrap=True)._action_done()
+ scrap.write({'move_id': move.id, 'state': 'done'})
+ scrap.date_done = fields.Datetime.now()
+ return True
+
+ def action_get_stock_picking(self):
+ action = self.env['ir.actions.act_window']._for_xml_id('stock.action_picking_tree_all')
+ action['domain'] = [('id', '=', self.picking_id.id)]
+ return action
+
+ def action_get_stock_move_lines(self):
+ action = self.env['ir.actions.act_window']._for_xml_id('stock.stock_move_line_action')
+ action['domain'] = [('move_id', '=', self.move_id.id)]
+ return action
+
+ def action_validate(self):
+ self.ensure_one()
+ if self.product_id.type != 'product':
+ return self.do_scrap()
+ precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+ available_qty = sum(self.env['stock.quant']._gather(self.product_id,
+ self.location_id,
+ self.lot_id,
+ self.package_id,
+ self.owner_id,
+ strict=True).mapped('quantity'))
+ scrap_qty = self.product_uom_id._compute_quantity(self.scrap_qty, self.product_id.uom_id)
+ if float_compare(available_qty, scrap_qty, precision_digits=precision) >= 0:
+ return self.do_scrap()
+ else:
+ ctx = dict(self.env.context)
+ ctx.update({
+ 'default_product_id': self.product_id.id,
+ 'default_location_id': self.location_id.id,
+ 'default_scrap_id': self.id,
+ 'default_quantity': scrap_qty,
+ 'default_product_uom_name': self.product_id.uom_name
+ })
+ return {
+ 'name': self.product_id.display_name + _(': Insufficient Quantity To Scrap'),
+ 'view_mode': 'form',
+ 'res_model': 'stock.warn.insufficient.qty.scrap',
+ 'view_id': self.env.ref('stock.stock_warn_insufficient_qty_scrap_form_view').id,
+ 'type': 'ir.actions.act_window',
+ 'context': ctx,
+ 'target': 'new'
+ }
diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py
new file mode 100644
index 00000000..a17d82be
--- /dev/null
+++ b/addons/stock/models/stock_warehouse.py
@@ -0,0 +1,1048 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+from collections import namedtuple
+
+from odoo import _, _lt, api, fields, models
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+ROUTE_NAMES = {
+ 'one_step': _lt('Receive in 1 step (stock)'),
+ 'two_steps': _lt('Receive in 2 steps (input + stock)'),
+ 'three_steps': _lt('Receive in 3 steps (input + quality + stock)'),
+ 'crossdock': _lt('Cross-Dock'),
+ 'ship_only': _lt('Deliver in 1 step (ship)'),
+ 'pick_ship': _lt('Deliver in 2 steps (pick + ship)'),
+ 'pick_pack_ship': _lt('Deliver in 3 steps (pick + pack + ship)'),
+}
+
+
+class Warehouse(models.Model):
+ _name = "stock.warehouse"
+ _description = "Warehouse"
+ _order = 'sequence,id'
+ _check_company_auto = True
+ # namedtuple used in helper methods generating values for routes
+ Routing = namedtuple('Routing', ['from_loc', 'dest_loc', 'picking_type', 'action'])
+
+ name = fields.Char('Warehouse', index=True, required=True, default=lambda self: self.env.company.name)
+ active = fields.Boolean('Active', default=True)
+ company_id = fields.Many2one(
+ 'res.company', 'Company', default=lambda self: self.env.company,
+ index=True, readonly=True, required=True,
+ help='The company is automatically set from your user preferences.')
+ partner_id = fields.Many2one('res.partner', 'Address', default=lambda self: self.env.company.partner_id, check_company=True)
+ view_location_id = fields.Many2one(
+ 'stock.location', 'View Location',
+ domain="[('usage', '=', 'view'), ('company_id', '=', company_id)]",
+ required=True, check_company=True)
+ lot_stock_id = fields.Many2one(
+ 'stock.location', 'Location Stock',
+ domain="[('usage', '=', 'internal'), ('company_id', '=', company_id)]",
+ required=True, check_company=True)
+ code = fields.Char('Short Name', required=True, size=5, help="Short name used to identify your warehouse")
+ route_ids = fields.Many2many(
+ 'stock.location.route', 'stock_route_warehouse', 'warehouse_id', 'route_id',
+ 'Routes',
+ domain="[('warehouse_selectable', '=', True), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
+ help='Defaults routes through the warehouse', check_company=True)
+ reception_steps = fields.Selection([
+ ('one_step', 'Receive goods directly (1 step)'),
+ ('two_steps', 'Receive goods in input and then stock (2 steps)'),
+ ('three_steps', 'Receive goods in input, then quality and then stock (3 steps)')],
+ 'Incoming Shipments', default='one_step', required=True,
+ help="Default incoming route to follow")
+ delivery_steps = fields.Selection([
+ ('ship_only', 'Deliver goods directly (1 step)'),
+ ('pick_ship', 'Send goods in output and then deliver (2 steps)'),
+ ('pick_pack_ship', 'Pack goods, send goods in output and then deliver (3 steps)')],
+ 'Outgoing Shipments', default='ship_only', required=True,
+ help="Default outgoing route to follow")
+ wh_input_stock_loc_id = fields.Many2one('stock.location', 'Input Location', check_company=True)
+ wh_qc_stock_loc_id = fields.Many2one('stock.location', 'Quality Control Location', check_company=True)
+ wh_output_stock_loc_id = fields.Many2one('stock.location', 'Output Location', check_company=True)
+ wh_pack_stock_loc_id = fields.Many2one('stock.location', 'Packing Location', check_company=True)
+ mto_pull_id = fields.Many2one('stock.rule', 'MTO rule')
+ pick_type_id = fields.Many2one('stock.picking.type', 'Pick Type', check_company=True)
+ pack_type_id = fields.Many2one('stock.picking.type', 'Pack Type', check_company=True)
+ out_type_id = fields.Many2one('stock.picking.type', 'Out Type', check_company=True)
+ in_type_id = fields.Many2one('stock.picking.type', 'In Type', check_company=True)
+ int_type_id = fields.Many2one('stock.picking.type', 'Internal Type', check_company=True)
+ crossdock_route_id = fields.Many2one('stock.location.route', 'Crossdock Route', ondelete='restrict')
+ reception_route_id = fields.Many2one('stock.location.route', 'Receipt Route', ondelete='restrict')
+ delivery_route_id = fields.Many2one('stock.location.route', 'Delivery Route', ondelete='restrict')
+ warehouse_count = fields.Integer(compute='_compute_warehouse_count')
+ resupply_wh_ids = fields.Many2many(
+ 'stock.warehouse', 'stock_wh_resupply_table', 'supplied_wh_id', 'supplier_wh_id',
+ 'Resupply From', help="Routes will be created automatically to resupply this warehouse from the warehouses ticked")
+ resupply_route_ids = fields.One2many(
+ 'stock.location.route', 'supplied_wh_id', 'Resupply Routes',
+ help="Routes will be created for these resupply warehouses and you can select them on products and product categories")
+ show_resupply = fields.Boolean(compute="_compute_show_resupply")
+ sequence = fields.Integer(default=10,
+ help="Gives the sequence of this line when displaying the warehouses.")
+ _sql_constraints = [
+ ('warehouse_name_uniq', 'unique(name, company_id)', 'The name of the warehouse must be unique per company!'),
+ ('warehouse_code_uniq', 'unique(code, company_id)', 'The code of the warehouse must be unique per company!'),
+ ]
+
+ @api.onchange('company_id')
+ def _onchange_company_id(self):
+ group_user = self.env.ref('base.group_user')
+ group_stock_multi_warehouses = self.env.ref('stock.group_stock_multi_warehouses')
+ if group_stock_multi_warehouses not in group_user.implied_ids:
+ return {
+ 'warning': {
+ 'title': _('Warning'),
+ 'message': _('Creating a new warehouse will automatically activate the Storage Locations setting')
+ }
+ }
+
+ @api.depends('name')
+ def _compute_warehouse_count(self):
+ for warehouse in self:
+ warehouse.warehouse_count = self.env['stock.warehouse'].search_count([('id', 'not in', warehouse.ids)])
+
+ def _compute_show_resupply(self):
+ for warehouse in self:
+ warehouse.show_resupply = warehouse.user_has_groups("stock.group_stock_multi_warehouses") and warehouse.warehouse_count
+
+ @api.model
+ def create(self, vals):
+ # create view location for warehouse then create all locations
+ loc_vals = {'name': vals.get('code'), 'usage': 'view',
+ 'location_id': self.env.ref('stock.stock_location_locations').id}
+ if vals.get('company_id'):
+ loc_vals['company_id'] = vals.get('company_id')
+ vals['view_location_id'] = self.env['stock.location'].create(loc_vals).id
+ sub_locations = self._get_locations_values(vals)
+
+ for field_name, values in sub_locations.items():
+ values['location_id'] = vals['view_location_id']
+ if vals.get('company_id'):
+ values['company_id'] = vals.get('company_id')
+ vals[field_name] = self.env['stock.location'].with_context(active_test=False).create(values).id
+
+ # actually create WH
+ warehouse = super(Warehouse, self).create(vals)
+ # create sequences and operation types
+ new_vals = warehouse._create_or_update_sequences_and_picking_types()
+ warehouse.write(new_vals) # TDE FIXME: use super ?
+ # create routes and push/stock rules
+ route_vals = warehouse._create_or_update_route()
+ warehouse.write(route_vals)
+
+ # Update global route with specific warehouse rule.
+ warehouse._create_or_update_global_routes_rules()
+
+ # create route selectable on the product to resupply the warehouse from another one
+ warehouse.create_resupply_routes(warehouse.resupply_wh_ids)
+
+ # update partner data if partner assigned
+ if vals.get('partner_id'):
+ self._update_partner_data(vals['partner_id'], vals.get('company_id'))
+
+ self._check_multiwarehouse_group()
+
+ return warehouse
+
+ def write(self, vals):
+ if 'company_id' in vals:
+ for warehouse in self:
+ if warehouse.company_id.id != vals['company_id']:
+ raise UserError(_("Changing the company of this record is forbidden at this point, you should rather archive it and create a new one."))
+
+ Route = self.env['stock.location.route']
+ warehouses = self.with_context(active_test=False)
+ warehouses._create_missing_locations(vals)
+
+ if vals.get('reception_steps'):
+ warehouses._update_location_reception(vals['reception_steps'])
+ if vals.get('delivery_steps'):
+ warehouses._update_location_delivery(vals['delivery_steps'])
+ if vals.get('reception_steps') or vals.get('delivery_steps'):
+ warehouses._update_reception_delivery_resupply(vals.get('reception_steps'), vals.get('delivery_steps'))
+
+ if vals.get('resupply_wh_ids') and not vals.get('resupply_route_ids'):
+ new_resupply_whs = self.new({
+ 'resupply_wh_ids': vals['resupply_wh_ids']
+ }).resupply_wh_ids._origin
+ old_resupply_whs = {warehouse.id: warehouse.resupply_wh_ids for warehouse in warehouses}
+
+ # If another partner assigned
+ if vals.get('partner_id'):
+ warehouses._update_partner_data(vals['partner_id'], vals.get('company_id'))
+
+ res = super(Warehouse, self).write(vals)
+
+ if vals.get('code') or vals.get('name'):
+ warehouses._update_name_and_code(vals.get('name'), vals.get('code'))
+
+ for warehouse in warehouses:
+ # check if we need to delete and recreate route
+ depends = [depend for depends in [value.get('depends', []) for value in warehouse._get_routes_values().values()] for depend in depends]
+ if 'code' in vals or any(depend in vals for depend in depends):
+ picking_type_vals = warehouse._create_or_update_sequences_and_picking_types()
+ if picking_type_vals:
+ warehouse.write(picking_type_vals)
+ if any(depend in vals for depend in depends):
+ route_vals = warehouse._create_or_update_route()
+ if route_vals:
+ warehouse.write(route_vals)
+ # Check if a global rule(mto, buy, ...) need to be modify.
+ # The field that impact those rules are listed in the
+ # _get_global_route_rules_values method under the key named
+ # 'depends'.
+ global_rules = warehouse._get_global_route_rules_values()
+ depends = [depend for depends in [value.get('depends', []) for value in global_rules.values()] for depend in depends]
+ if any(rule in vals for rule in global_rules) or\
+ any(depend in vals for depend in depends):
+ warehouse._create_or_update_global_routes_rules()
+
+ if 'active' in vals:
+ picking_type_ids = self.env['stock.picking.type'].with_context(active_test=False).search([('warehouse_id', '=', warehouse.id)])
+ move_ids = self.env['stock.move'].search([
+ ('picking_type_id', 'in', picking_type_ids.ids),
+ ('state', 'not in', ('done', 'cancel')),
+ ])
+ if move_ids:
+ raise UserError(_('You still have ongoing operations for picking types %s in warehouse %s') %
+ (', '.join(move_ids.mapped('picking_type_id.name')), warehouse.name))
+ else:
+ picking_type_ids.write({'active': vals['active']})
+ location_ids = self.env['stock.location'].with_context(active_test=False).search([('location_id', 'child_of', warehouse.view_location_id.id)])
+ picking_type_using_locations = self.env['stock.picking.type'].search([
+ ('default_location_src_id', 'in', location_ids.ids),
+ ('default_location_dest_id', 'in', location_ids.ids),
+ ('id', 'not in', picking_type_ids.ids),
+ ])
+ if picking_type_using_locations:
+ raise UserError(_('%s use default source or destination locations from warehouse %s that will be archived.') %
+ (', '.join(picking_type_using_locations.mapped('name')), warehouse.name))
+ warehouse.view_location_id.write({'active': vals['active']})
+
+ rule_ids = self.env['stock.rule'].with_context(active_test=False).search([('warehouse_id', '=', warehouse.id)])
+ # Only modify route that apply on this warehouse.
+ warehouse.route_ids.filtered(lambda r: len(r.warehouse_ids) == 1).write({'active': vals['active']})
+ rule_ids.write({'active': vals['active']})
+
+ if warehouse.active:
+ # Catch all warehouse fields that trigger a modfication on
+ # routes, rules, picking types and locations (e.g the reception
+ # steps). The purpose is to write on it in order to let the
+ # write method set the correct field to active or archive.
+ depends = set([])
+ for rule_item in warehouse._get_global_route_rules_values().values():
+ for depend in rule_item.get('depends', []):
+ depends.add(depend)
+ for rule_item in warehouse._get_routes_values().values():
+ for depend in rule_item.get('depends', []):
+ depends.add(depend)
+ values = {'resupply_route_ids': [(4, route.id) for route in warehouse.resupply_route_ids]}
+ for depend in depends:
+ values.update({depend: warehouse[depend]})
+ warehouse.write(values)
+
+ if vals.get('resupply_wh_ids') and not vals.get('resupply_route_ids'):
+ for warehouse in warehouses:
+ to_add = new_resupply_whs - old_resupply_whs[warehouse.id]
+ to_remove = old_resupply_whs[warehouse.id] - new_resupply_whs
+ if to_add:
+ existing_route = Route.search([
+ ('supplied_wh_id', '=', warehouse.id),
+ ('supplier_wh_id', 'in', to_remove.ids),
+ ('active', '=', False)
+ ])
+ if existing_route:
+ existing_route.toggle_active()
+ else:
+ warehouse.create_resupply_routes(to_add)
+ if to_remove:
+ to_disable_route_ids = Route.search([
+ ('supplied_wh_id', '=', warehouse.id),
+ ('supplier_wh_id', 'in', to_remove.ids),
+ ('active', '=', True)
+ ])
+ to_disable_route_ids.toggle_active()
+
+ if 'active' in vals:
+ self._check_multiwarehouse_group()
+ return res
+
+ def unlink(self):
+ res = super().unlink()
+ self._check_multiwarehouse_group()
+ return res
+
+ def _check_multiwarehouse_group(self):
+ cnt_by_company = self.env['stock.warehouse'].sudo().read_group([('active', '=', True)], ['company_id'], groupby=['company_id'])
+ if cnt_by_company:
+ max_cnt = max(cnt_by_company, key=lambda k: k['company_id_count'])
+ group_user = self.env.ref('base.group_user')
+ group_stock_multi_warehouses = self.env.ref('stock.group_stock_multi_warehouses')
+ if max_cnt['company_id_count'] <= 1 and group_stock_multi_warehouses in group_user.implied_ids:
+ group_user.write({'implied_ids': [(3, group_stock_multi_warehouses.id)]})
+ group_stock_multi_warehouses.write({'users': [(3, user.id) for user in group_user.users]})
+ if max_cnt['company_id_count'] > 1 and group_stock_multi_warehouses not in group_user.implied_ids:
+ group_user.write({'implied_ids': [(4, group_stock_multi_warehouses.id), (4, self.env.ref('stock.group_stock_multi_locations').id)]})
+
+ @api.model
+ def _update_partner_data(self, partner_id, company_id):
+ if not partner_id:
+ return
+ ResCompany = self.env['res.company']
+ if company_id:
+ transit_loc = ResCompany.browse(company_id).internal_transit_location_id.id
+ self.env['res.partner'].browse(partner_id).with_company(company_id).write({'property_stock_customer': transit_loc, 'property_stock_supplier': transit_loc})
+ else:
+ transit_loc = self.env.company.internal_transit_location_id.id
+ self.env['res.partner'].browse(partner_id).write({'property_stock_customer': transit_loc, 'property_stock_supplier': transit_loc})
+
+ def _create_or_update_sequences_and_picking_types(self):
+ """ Create or update existing picking types for a warehouse.
+ Pikcing types are stored on the warehouse in a many2one. If the picking
+ type exist this method will update it. The update values can be found in
+ the method _get_picking_type_update_values. If the picking type does not
+ exist it will be created with a new sequence associated to it.
+ """
+ self.ensure_one()
+ IrSequenceSudo = self.env['ir.sequence'].sudo()
+ PickingType = self.env['stock.picking.type']
+
+ # choose the next available color for the operation types of this warehouse
+ all_used_colors = [res['color'] for res in PickingType.search_read([('warehouse_id', '!=', False), ('color', '!=', False)], ['color'], order='color')]
+ available_colors = [zef for zef in range(0, 12) if zef not in all_used_colors]
+ color = available_colors[0] if available_colors else 0
+
+ warehouse_data = {}
+ sequence_data = self._get_sequence_values()
+
+ # suit for each warehouse: reception, internal, pick, pack, ship
+ max_sequence = self.env['stock.picking.type'].search_read([('sequence', '!=', False)], ['sequence'], limit=1, order='sequence desc')
+ max_sequence = max_sequence and max_sequence[0]['sequence'] or 0
+
+ data = self._get_picking_type_update_values()
+ create_data, max_sequence = self._get_picking_type_create_values(max_sequence)
+
+ for picking_type, values in data.items():
+ if self[picking_type]:
+ self[picking_type].update(values)
+ else:
+ data[picking_type].update(create_data[picking_type])
+ sequence = IrSequenceSudo.create(sequence_data[picking_type])
+ values.update(warehouse_id=self.id, color=color, sequence_id=sequence.id)
+ warehouse_data[picking_type] = PickingType.create(values).id
+
+ if 'out_type_id' in warehouse_data:
+ PickingType.browse(warehouse_data['out_type_id']).write({'return_picking_type_id': warehouse_data.get('in_type_id', False)})
+ if 'in_type_id' in warehouse_data:
+ PickingType.browse(warehouse_data['in_type_id']).write({'return_picking_type_id': warehouse_data.get('out_type_id', False)})
+ return warehouse_data
+
+ def _create_or_update_global_routes_rules(self):
+ """ Some rules are not specific to a warehouse(e.g MTO, Buy, ...)
+ however they contain rule(s) for a specific warehouse. This method will
+ update the rules contained in global routes in order to make them match
+ with the wanted reception, delivery,... steps.
+ """
+ for rule_field, rule_details in self._get_global_route_rules_values().items():
+ values = rule_details.get('update_values', {})
+ if self[rule_field]:
+ self[rule_field].write(values)
+ else:
+ values.update(rule_details['create_values'])
+ values.update({'warehouse_id': self.id})
+ self[rule_field] = self.env['stock.rule'].create(values)
+ return True
+
+ def _find_global_route(self, xml_id, route_name):
+ """ return a route record set from an xml_id or its name. """
+ route = self.env.ref(xml_id, raise_if_not_found=False)
+ if not route:
+ route = self.env['stock.location.route'].search([('name', 'like', route_name)], limit=1)
+ if not route:
+ raise UserError(_('Can\'t find any generic route %s.') % (route_name))
+ return route
+
+ def _get_global_route_rules_values(self):
+ """ Method used by _create_or_update_global_routes_rules. It's
+ purpose is to return a dict with this format.
+ key: The rule contained in a global route that have to be create/update
+ entry a dict with the following values:
+ -depends: Field that impact the rule. When a field in depends is
+ write on the warehouse the rule set as key have to be update.
+ -create_values: values used in order to create the rule if it does
+ not exist.
+ -update_values: values used to update the route when a field in
+ depends is modify on the warehouse.
+ """
+ # We use 0 since routing are order from stock to cust. If the routing
+ # order is modify, the mto rule will be wrong.
+ rule = self.get_rules_dict()[self.id][self.delivery_steps]
+ rule = [r for r in rule if r.from_loc == self.lot_stock_id][0]
+ location_id = rule.from_loc
+ location_dest_id = rule.dest_loc
+ picking_type_id = rule.picking_type
+ return {
+ 'mto_pull_id': {
+ 'depends': ['delivery_steps'],
+ 'create_values': {
+ 'active': True,
+ 'procure_method': 'mts_else_mto',
+ 'company_id': self.company_id.id,
+ 'action': 'pull',
+ 'auto': 'manual',
+ 'route_id': self._find_global_route('stock.route_warehouse0_mto', _('Make To Order')).id
+ },
+ 'update_values': {
+ 'name': self._format_rulename(location_id, location_dest_id, 'MTO'),
+ 'location_id': location_dest_id.id,
+ 'location_src_id': location_id.id,
+ 'picking_type_id': picking_type_id.id,
+ }
+ }
+ }
+
+ def _create_or_update_route(self):
+ """ Create or update the warehouse's routes.
+ _get_routes_values method return a dict with:
+ - route field name (e.g: crossdock_route_id).
+ - field that trigger an update on the route (key 'depends').
+ - routing_key used in order to find rules contained in the route.
+ - create values.
+ - update values when a field in depends is modified.
+ - rules default values.
+ This method do an iteration on each route returned and update/create
+ them. In order to update the rules contained in the route it will
+ use the get_rules_dict that return a dict:
+ - a receptions/delivery,... step value as key (e.g 'pick_ship')
+ - a list of routing object that represents the rules needed to
+ fullfil the pupose of the route.
+ The routing_key from _get_routes_values is match with the get_rules_dict
+ key in order to create/update the rules in the route
+ (_find_existing_rule_or_create method is responsible for this part).
+ """
+ # Create routes and active/create their related rules.
+ routes = []
+ rules_dict = self.get_rules_dict()
+ for route_field, route_data in self._get_routes_values().items():
+ # If the route exists update it
+ if self[route_field]:
+ route = self[route_field]
+ if 'route_update_values' in route_data:
+ route.write(route_data['route_update_values'])
+ route.rule_ids.write({'active': False})
+ # Create the route
+ else:
+ if 'route_update_values' in route_data:
+ route_data['route_create_values'].update(route_data['route_update_values'])
+ route = self.env['stock.location.route'].create(route_data['route_create_values'])
+ self[route_field] = route
+ # Get rules needed for the route
+ routing_key = route_data.get('routing_key')
+ rules = rules_dict[self.id][routing_key]
+ if 'rules_values' in route_data:
+ route_data['rules_values'].update({'route_id': route.id})
+ else:
+ route_data['rules_values'] = {'route_id': route.id}
+ rules_list = self._get_rule_values(
+ rules, values=route_data['rules_values'])
+ # Create/Active rules
+ self._find_existing_rule_or_create(rules_list)
+ if route_data['route_create_values'].get('warehouse_selectable', False) or route_data['route_update_values'].get('warehouse_selectable', False):
+ routes.append(self[route_field])
+ return {
+ 'route_ids': [(4, route.id) for route in routes],
+ }
+
+ def _get_routes_values(self):
+ """ Return information in order to update warehouse routes.
+ - The key is a route field sotred as a Many2one on the warehouse
+ - This key contains a dict with route values:
+ - routing_key: a key used in order to match rules from
+ get_rules_dict function. It would be usefull in order to generate
+ the route's rules.
+ - route_create_values: When the Many2one does not exist the route
+ is created based on values contained in this dict.
+ - route_update_values: When a field contained in 'depends' key is
+ modified and the Many2one exist on the warehouse, the route will be
+ update with the values contained in this dict.
+ - rules_values: values added to the routing in order to create the
+ route's rules.
+ """
+ return {
+ 'reception_route_id': {
+ 'routing_key': self.reception_steps,
+ 'depends': ['reception_steps'],
+ 'route_update_values': {
+ 'name': self._format_routename(route_type=self.reception_steps),
+ 'active': self.active,
+ },
+ 'route_create_values': {
+ 'product_categ_selectable': True,
+ 'warehouse_selectable': True,
+ 'product_selectable': False,
+ 'company_id': self.company_id.id,
+ 'sequence': 9,
+ },
+ 'rules_values': {
+ 'active': True,
+ 'propagate_cancel': True,
+ }
+ },
+ 'delivery_route_id': {
+ 'routing_key': self.delivery_steps,
+ 'depends': ['delivery_steps'],
+ 'route_update_values': {
+ 'name': self._format_routename(route_type=self.delivery_steps),
+ 'active': self.active,
+ },
+ 'route_create_values': {
+ 'product_categ_selectable': True,
+ 'warehouse_selectable': True,
+ 'product_selectable': False,
+ 'company_id': self.company_id.id,
+ 'sequence': 10,
+ },
+ 'rules_values': {
+ 'active': True,
+ }
+ },
+ 'crossdock_route_id': {
+ 'routing_key': 'crossdock',
+ 'depends': ['delivery_steps', 'reception_steps'],
+ 'route_update_values': {
+ 'name': self._format_routename(route_type='crossdock'),
+ 'active': self.reception_steps != 'one_step' and self.delivery_steps != 'ship_only'
+ },
+ 'route_create_values': {
+ 'product_selectable': True,
+ 'product_categ_selectable': True,
+ 'active': self.delivery_steps != 'ship_only' and self.reception_steps != 'one_step',
+ 'company_id': self.company_id.id,
+ 'sequence': 20,
+ },
+ 'rules_values': {
+ 'active': True,
+ 'procure_method': 'make_to_order'
+ }
+ }
+ }
+
+ def _get_receive_routes_values(self, installed_depends):
+ """ Return receive route values with 'procure_method': 'make_to_order'
+ in order to update warehouse routes.
+
+ This function has the same receive route values as _get_routes_values with the addition of
+ 'procure_method': 'make_to_order' to the 'rules_values'. This is expected to be used by
+ modules that extend stock and add actions that can trigger receive 'make_to_order' rules (i.e.
+ we don't want any of the generated rules by get_rules_dict to default to 'make_to_stock').
+ Additionally this is expected to be used in conjunction with _get_receive_rules_dict().
+
+ args:
+ installed_depends - string value of installed (warehouse) boolean to trigger updating of reception route.
+ """
+ return {
+ 'reception_route_id': {
+ 'routing_key': self.reception_steps,
+ 'depends': ['reception_steps', installed_depends],
+ 'route_update_values': {
+ 'name': self._format_routename(route_type=self.reception_steps),
+ 'active': self.active,
+ },
+ 'route_create_values': {
+ 'product_categ_selectable': True,
+ 'warehouse_selectable': True,
+ 'product_selectable': False,
+ 'company_id': self.company_id.id,
+ 'sequence': 9,
+ },
+ 'rules_values': {
+ 'active': True,
+ 'propagate_cancel': True,
+ 'procure_method': 'make_to_order',
+ }
+ }
+ }
+
+ def _find_existing_rule_or_create(self, rules_list):
+ """ This method will find existing rules or create new one. """
+ for rule_vals in rules_list:
+ existing_rule = self.env['stock.rule'].search([
+ ('picking_type_id', '=', rule_vals['picking_type_id']),
+ ('location_src_id', '=', rule_vals['location_src_id']),
+ ('location_id', '=', rule_vals['location_id']),
+ ('route_id', '=', rule_vals['route_id']),
+ ('action', '=', rule_vals['action']),
+ ('active', '=', False),
+ ])
+ if not existing_rule:
+ self.env['stock.rule'].create(rule_vals)
+ else:
+ existing_rule.write({'active': True})
+
+ def _get_locations_values(self, vals, code=False):
+ """ Update the warehouse locations. """
+ def_values = self.default_get(['reception_steps', 'delivery_steps'])
+ reception_steps = vals.get('reception_steps', def_values['reception_steps'])
+ delivery_steps = vals.get('delivery_steps', def_values['delivery_steps'])
+ code = vals.get('code') or code or ''
+ code = code.replace(' ', '').upper()
+ company_id = vals.get('company_id', self.default_get(['company_id'])['company_id'])
+ sub_locations = {
+ 'lot_stock_id': {
+ 'name': _('Stock'),
+ 'active': True,
+ 'usage': 'internal',
+ 'barcode': self._valid_barcode(code + '-STOCK', company_id)
+ },
+ 'wh_input_stock_loc_id': {
+ 'name': _('Input'),
+ 'active': reception_steps != 'one_step',
+ 'usage': 'internal',
+ 'barcode': self._valid_barcode(code + '-INPUT', company_id)
+ },
+ 'wh_qc_stock_loc_id': {
+ 'name': _('Quality Control'),
+ 'active': reception_steps == 'three_steps',
+ 'usage': 'internal',
+ 'barcode': self._valid_barcode(code + '-QUALITY', company_id)
+ },
+ 'wh_output_stock_loc_id': {
+ 'name': _('Output'),
+ 'active': delivery_steps != 'ship_only',
+ 'usage': 'internal',
+ 'barcode': self._valid_barcode(code + '-OUTPUT', company_id)
+ },
+ 'wh_pack_stock_loc_id': {
+ 'name': _('Packing Zone'),
+ 'active': delivery_steps == 'pick_pack_ship',
+ 'usage': 'internal',
+ 'barcode': self._valid_barcode(code + '-PACKING', company_id)
+ },
+ }
+ return sub_locations
+
+ def _valid_barcode(self, barcode, company_id):
+ location = self.env['stock.location'].with_context(active_test=False).search([
+ ('barcode', '=', barcode),
+ ('company_id', '=', company_id)
+ ])
+ return not location and barcode
+
+ def _create_missing_locations(self, vals):
+ """ It could happen that the user delete a mandatory location or a
+ module with new locations was installed after some warehouses creation.
+ In this case, this function will create missing locations in order to
+ avoid mistakes during picking types and rules creation.
+ """
+ for warehouse in self:
+ company_id = vals.get('company_id', warehouse.company_id.id)
+ sub_locations = warehouse._get_locations_values(dict(vals, company_id=company_id), warehouse.code)
+ missing_location = {}
+ for location, location_values in sub_locations.items():
+ if not warehouse[location] and location not in vals:
+ location_values['location_id'] = vals.get('view_location_id', warehouse.view_location_id.id)
+ location_values['company_id'] = company_id
+ missing_location[location] = self.env['stock.location'].create(location_values).id
+ if missing_location:
+ warehouse.write(missing_location)
+
+ def create_resupply_routes(self, supplier_warehouses):
+ Route = self.env['stock.location.route']
+ Rule = self.env['stock.rule']
+
+ input_location, output_location = self._get_input_output_locations(self.reception_steps, self.delivery_steps)
+ internal_transit_location, external_transit_location = self._get_transit_locations()
+
+ for supplier_wh in supplier_warehouses:
+ transit_location = internal_transit_location if supplier_wh.company_id == self.company_id else external_transit_location
+ if not transit_location:
+ continue
+ transit_location.active = True
+ output_location = supplier_wh.lot_stock_id if supplier_wh.delivery_steps == 'ship_only' else supplier_wh.wh_output_stock_loc_id
+ # Create extra MTO rule (only for 'ship only' because in the other cases MTO rules already exists)
+ if supplier_wh.delivery_steps == 'ship_only':
+ routing = [self.Routing(output_location, transit_location, supplier_wh.out_type_id, 'pull')]
+ mto_vals = supplier_wh._get_global_route_rules_values().get('mto_pull_id')
+ values = mto_vals['create_values']
+ mto_rule_val = supplier_wh._get_rule_values(routing, values, name_suffix='MTO')
+ Rule.create(mto_rule_val[0])
+
+ inter_wh_route = Route.create(self._get_inter_warehouse_route_values(supplier_wh))
+
+ pull_rules_list = supplier_wh._get_supply_pull_rules_values(
+ [self.Routing(output_location, transit_location, supplier_wh.out_type_id, 'pull')],
+ values={'route_id': inter_wh_route.id})
+ pull_rules_list += self._get_supply_pull_rules_values(
+ [self.Routing(transit_location, input_location, self.in_type_id, 'pull')],
+ values={'route_id': inter_wh_route.id, 'propagate_warehouse_id': supplier_wh.id})
+ for pull_rule_vals in pull_rules_list:
+ Rule.create(pull_rule_vals)
+
+ # Routing tools
+ # ------------------------------------------------------------
+
+ def _get_input_output_locations(self, reception_steps, delivery_steps):
+ return (self.lot_stock_id if reception_steps == 'one_step' else self.wh_input_stock_loc_id,
+ self.lot_stock_id if delivery_steps == 'ship_only' else self.wh_output_stock_loc_id)
+
+ def _get_transit_locations(self):
+ return self.company_id.internal_transit_location_id, self.env.ref('stock.stock_location_inter_wh', raise_if_not_found=False) or self.env['stock.location']
+
+ @api.model
+ def _get_partner_locations(self):
+ ''' returns a tuple made of the browse record of customer location and the browse record of supplier location'''
+ Location = self.env['stock.location']
+ customer_loc = self.env.ref('stock.stock_location_customers', raise_if_not_found=False)
+ supplier_loc = self.env.ref('stock.stock_location_suppliers', raise_if_not_found=False)
+ if not customer_loc:
+ customer_loc = Location.search([('usage', '=', 'customer')], limit=1)
+ if not supplier_loc:
+ supplier_loc = Location.search([('usage', '=', 'supplier')], limit=1)
+ if not customer_loc and not supplier_loc:
+ raise UserError(_('Can\'t find any customer or supplier location.'))
+ return customer_loc, supplier_loc
+
+ def _get_route_name(self, route_type):
+ return str(ROUTE_NAMES[route_type])
+
+ def get_rules_dict(self):
+ """ Define the rules source/destination locations, picking_type and
+ action needed for each warehouse route configuration.
+ """
+ customer_loc, supplier_loc = self._get_partner_locations()
+ return {
+ warehouse.id: {
+ 'one_step': [self.Routing(supplier_loc, warehouse.lot_stock_id, warehouse.in_type_id, 'pull')],
+ 'two_steps': [
+ self.Routing(supplier_loc, warehouse.wh_input_stock_loc_id, warehouse.in_type_id, 'pull'),
+ self.Routing(warehouse.wh_input_stock_loc_id, warehouse.lot_stock_id, warehouse.int_type_id, 'pull_push')],
+ 'three_steps': [
+ self.Routing(supplier_loc, warehouse.wh_input_stock_loc_id, warehouse.in_type_id, 'pull'),
+ self.Routing(warehouse.wh_input_stock_loc_id, warehouse.wh_qc_stock_loc_id, warehouse.int_type_id, 'pull_push'),
+ self.Routing(warehouse.wh_qc_stock_loc_id, warehouse.lot_stock_id, warehouse.int_type_id, 'pull_push')],
+ 'crossdock': [
+ self.Routing(warehouse.wh_input_stock_loc_id, warehouse.wh_output_stock_loc_id, warehouse.int_type_id, 'pull'),
+ self.Routing(warehouse.wh_output_stock_loc_id, customer_loc, warehouse.out_type_id, 'pull')],
+ 'ship_only': [self.Routing(warehouse.lot_stock_id, customer_loc, warehouse.out_type_id, 'pull')],
+ 'pick_ship': [
+ self.Routing(warehouse.lot_stock_id, warehouse.wh_output_stock_loc_id, warehouse.pick_type_id, 'pull'),
+ self.Routing(warehouse.wh_output_stock_loc_id, customer_loc, warehouse.out_type_id, 'pull')],
+ 'pick_pack_ship': [
+ self.Routing(warehouse.lot_stock_id, warehouse.wh_pack_stock_loc_id, warehouse.pick_type_id, 'pull'),
+ self.Routing(warehouse.wh_pack_stock_loc_id, warehouse.wh_output_stock_loc_id, warehouse.pack_type_id, 'pull'),
+ self.Routing(warehouse.wh_output_stock_loc_id, customer_loc, warehouse.out_type_id, 'pull')],
+ 'company_id': warehouse.company_id.id,
+ } for warehouse in self
+ }
+
+ def _get_receive_rules_dict(self):
+ """ Return receive route rules without initial pull rule in order to update warehouse routes.
+
+ This function has the same receive route rules as get_rules_dict without an initial pull rule.
+ This is expected to be used by modules that extend stock and add actions that can trigger receive
+ 'make_to_order' rules (i.e. we don't expect the receive route to be able to pull on its own anymore).
+ This is also expected to be used in conjuction with _get_receive_routes_values()
+ """
+ return {
+ 'one_step': [],
+ 'two_steps': [self.Routing(self.wh_input_stock_loc_id, self.lot_stock_id, self.int_type_id, 'pull_push')],
+ 'three_steps': [
+ self.Routing(self.wh_input_stock_loc_id, self.wh_qc_stock_loc_id, self.int_type_id, 'pull_push'),
+ self.Routing(self.wh_qc_stock_loc_id, self.lot_stock_id, self.int_type_id, 'pull_push')],
+ }
+
+ def _get_inter_warehouse_route_values(self, supplier_warehouse):
+ return {
+ 'name': _('%(warehouse)s: Supply Product from %(supplier)s', warehouse=self.name, supplier=supplier_warehouse.name),
+ 'warehouse_selectable': True,
+ 'product_selectable': True,
+ 'product_categ_selectable': True,
+ 'supplied_wh_id': self.id,
+ 'supplier_wh_id': supplier_warehouse.id,
+ 'company_id': self.company_id.id,
+ }
+
+ # Pull / Push tools
+ # ------------------------------------------------------------
+
+ def _get_rule_values(self, route_values, values=None, name_suffix=''):
+ first_rule = True
+ rules_list = []
+ for routing in route_values:
+ route_rule_values = {
+ 'name': self._format_rulename(routing.from_loc, routing.dest_loc, name_suffix),
+ 'location_src_id': routing.from_loc.id,
+ 'location_id': routing.dest_loc.id,
+ 'action': routing.action,
+ 'auto': 'manual',
+ 'picking_type_id': routing.picking_type.id,
+ 'procure_method': first_rule and 'make_to_stock' or 'make_to_order',
+ 'warehouse_id': self.id,
+ 'company_id': self.company_id.id,
+ }
+ route_rule_values.update(values or {})
+ rules_list.append(route_rule_values)
+ first_rule = False
+ if values and values.get('propagate_cancel') and rules_list:
+ # In case of rules chain with cancel propagation set, we need to stop
+ # the cancellation for the last step in order to avoid cancelling
+ # any other move after the chain.
+ # Example: In the following flow:
+ # Input -> Quality check -> Stock -> Customer
+ # We want that cancelling I->GC cancel QC -> S but not S -> C
+ # which means:
+ # Input -> Quality check should have propagate_cancel = True
+ # Quality check -> Stock should have propagate_cancel = False
+ rules_list[-1]['propagate_cancel'] = False
+ return rules_list
+
+ def _get_supply_pull_rules_values(self, route_values, values=None):
+ pull_values = {}
+ pull_values.update(values)
+ pull_values.update({'active': True})
+ rules_list = self._get_rule_values(route_values, values=pull_values)
+ for pull_rules in rules_list:
+ pull_rules['procure_method'] = self.lot_stock_id.id != pull_rules['location_src_id'] and 'make_to_order' or 'make_to_stock' # first part of the resuply route is MTS
+ return rules_list
+
+ def _update_reception_delivery_resupply(self, reception_new, delivery_new):
+ """ Check if we need to change something to resupply warehouses and associated MTO rules """
+ for warehouse in self:
+ input_loc, output_loc = warehouse._get_input_output_locations(reception_new, delivery_new)
+ if reception_new and warehouse.reception_steps != reception_new and (warehouse.reception_steps == 'one_step' or reception_new == 'one_step'):
+ warehouse._check_reception_resupply(input_loc)
+ if delivery_new and warehouse.delivery_steps != delivery_new and (warehouse.delivery_steps == 'ship_only' or delivery_new == 'ship_only'):
+ change_to_multiple = warehouse.delivery_steps == 'ship_only'
+ warehouse._check_delivery_resupply(output_loc, change_to_multiple)
+
+ def _check_delivery_resupply(self, new_location, change_to_multiple):
+ """ Check if the resupply routes from this warehouse follow the changes of number of delivery steps
+ Check routes being delivery bu this warehouse and change the rule going to transit location """
+ Rule = self.env["stock.rule"]
+ routes = self.env['stock.location.route'].search([('supplier_wh_id', '=', self.id)])
+ rules = Rule.search(['&', '&', ('route_id', 'in', routes.ids), ('action', '!=', 'push'), ('location_id.usage', '=', 'transit')])
+ rules.write({
+ 'location_src_id': new_location.id,
+ 'procure_method': change_to_multiple and "make_to_order" or "make_to_stock"})
+ if not change_to_multiple:
+ # If single delivery we should create the necessary MTO rules for the resupply
+ routings = [self.Routing(self.lot_stock_id, location, self.out_type_id, 'pull') for location in rules.mapped('location_id')]
+ mto_vals = self._get_global_route_rules_values().get('mto_pull_id')
+ values = mto_vals['create_values']
+ mto_rule_vals = self._get_rule_values(routings, values, name_suffix='MTO')
+
+ for mto_rule_val in mto_rule_vals:
+ Rule.create(mto_rule_val)
+ else:
+ # We need to delete all the MTO stock rules, otherwise they risk to be used in the system
+ Rule.search([
+ '&', ('route_id', '=', self._find_global_route('stock.route_warehouse0_mto', _('Make To Order')).id),
+ ('location_id.usage', '=', 'transit'),
+ ('action', '!=', 'push'),
+ ('location_src_id', '=', self.lot_stock_id.id)]).write({'active': False})
+
+ def _check_reception_resupply(self, new_location):
+ """ Check routes being delivered by the warehouses (resupply routes) and
+ change their rule coming from the transit location """
+ routes = self.env['stock.location.route'].search([('supplied_wh_id', 'in', self.ids)])
+ self.env['stock.rule'].search([
+ '&',
+ ('route_id', 'in', routes.ids),
+ '&',
+ ('action', '!=', 'push'),
+ ('location_src_id.usage', '=', 'transit')
+ ]).write({'location_id': new_location.id})
+
+ def _update_name_and_code(self, new_name=False, new_code=False):
+ if new_code:
+ self.mapped('lot_stock_id').mapped('location_id').write({'name': new_code})
+ if new_name:
+ # TDE FIXME: replacing the route name ? not better to re-generate the route naming ?
+ for warehouse in self:
+ routes = warehouse.route_ids
+ for route in routes:
+ route.write({'name': route.name.replace(warehouse.name, new_name, 1)})
+ for pull in route.rule_ids:
+ pull.write({'name': pull.name.replace(warehouse.name, new_name, 1)})
+ if warehouse.mto_pull_id:
+ warehouse.mto_pull_id.write({'name': warehouse.mto_pull_id.name.replace(warehouse.name, new_name, 1)})
+ for warehouse in self:
+ sequence_data = warehouse._get_sequence_values()
+ # `ir.sequence` write access is limited to system user
+ if self.user_has_groups('stock.group_stock_manager'):
+ warehouse = warehouse.sudo()
+ warehouse.in_type_id.sequence_id.write(sequence_data['in_type_id'])
+ warehouse.out_type_id.sequence_id.write(sequence_data['out_type_id'])
+ warehouse.pack_type_id.sequence_id.write(sequence_data['pack_type_id'])
+ warehouse.pick_type_id.sequence_id.write(sequence_data['pick_type_id'])
+ warehouse.int_type_id.sequence_id.write(sequence_data['int_type_id'])
+
+ def _update_location_reception(self, new_reception_step):
+ self.mapped('wh_qc_stock_loc_id').write({'active': new_reception_step == 'three_steps'})
+ self.mapped('wh_input_stock_loc_id').write({'active': new_reception_step != 'one_step'})
+
+ def _update_location_delivery(self, new_delivery_step):
+ self.mapped('wh_pack_stock_loc_id').write({'active': new_delivery_step == 'pick_pack_ship'})
+ self.mapped('wh_output_stock_loc_id').write({'active': new_delivery_step != 'ship_only'})
+
+ # Misc
+ # ------------------------------------------------------------
+
+ def _get_picking_type_update_values(self):
+ """ Return values in order to update the existing picking type when the
+ warehouse's delivery_steps or reception_steps are modify.
+ """
+ input_loc, output_loc = self._get_input_output_locations(self.reception_steps, self.delivery_steps)
+ return {
+ 'in_type_id': {
+ 'default_location_dest_id': input_loc.id,
+ 'barcode': self.code.replace(" ", "").upper() + "-RECEIPTS",
+ },
+ 'out_type_id': {
+ 'default_location_src_id': output_loc.id,
+ 'barcode': self.code.replace(" ", "").upper() + "-DELIVERY",
+ },
+ 'pick_type_id': {
+ 'active': self.delivery_steps != 'ship_only' and self.active,
+ 'default_location_dest_id': output_loc.id if self.delivery_steps == 'pick_ship' else self.wh_pack_stock_loc_id.id,
+ 'barcode': self.code.replace(" ", "").upper() + "-PICK",
+ },
+ 'pack_type_id': {
+ 'active': self.delivery_steps == 'pick_pack_ship' and self.active,
+ 'barcode': self.code.replace(" ", "").upper() + "-PACK",
+ },
+ 'int_type_id': {
+ 'barcode': self.code.replace(" ", "").upper() + "-INTERNAL",
+ },
+ }
+
+ def _get_picking_type_create_values(self, max_sequence):
+ """ When a warehouse is created this method return the values needed in
+ order to create the new picking types for this warehouse. Every picking
+ type are created at the same time than the warehouse howver they are
+ activated or archived depending the delivery_steps or reception_steps.
+ """
+ input_loc, output_loc = self._get_input_output_locations(self.reception_steps, self.delivery_steps)
+ return {
+ 'in_type_id': {
+ 'name': _('Receipts'),
+ 'code': 'incoming',
+ 'use_create_lots': True,
+ 'use_existing_lots': False,
+ 'default_location_src_id': False,
+ 'sequence': max_sequence + 1,
+ 'show_reserved': False,
+ 'show_operations': False,
+ 'sequence_code': 'IN',
+ 'company_id': self.company_id.id,
+ }, 'out_type_id': {
+ 'name': _('Delivery Orders'),
+ 'code': 'outgoing',
+ 'use_create_lots': False,
+ 'use_existing_lots': True,
+ 'default_location_dest_id': False,
+ 'sequence': max_sequence + 5,
+ 'sequence_code': 'OUT',
+ 'company_id': self.company_id.id,
+ }, 'pack_type_id': {
+ 'name': _('Pack'),
+ 'code': 'internal',
+ 'use_create_lots': False,
+ 'use_existing_lots': True,
+ 'default_location_src_id': self.wh_pack_stock_loc_id.id,
+ 'default_location_dest_id': output_loc.id,
+ 'sequence': max_sequence + 4,
+ 'sequence_code': 'PACK',
+ 'company_id': self.company_id.id,
+ }, 'pick_type_id': {
+ 'name': _('Pick'),
+ 'code': 'internal',
+ 'use_create_lots': False,
+ 'use_existing_lots': True,
+ 'default_location_src_id': self.lot_stock_id.id,
+ 'sequence': max_sequence + 3,
+ 'sequence_code': 'PICK',
+ 'company_id': self.company_id.id,
+ }, 'int_type_id': {
+ 'name': _('Internal Transfers'),
+ 'code': 'internal',
+ 'use_create_lots': False,
+ 'use_existing_lots': True,
+ 'default_location_src_id': self.lot_stock_id.id,
+ 'default_location_dest_id': self.lot_stock_id.id,
+ 'active': self.reception_steps != 'one_step' or self.delivery_steps != 'ship_only' or self.user_has_groups('stock.group_stock_multi_locations'),
+ 'sequence': max_sequence + 2,
+ 'sequence_code': 'INT',
+ 'company_id': self.company_id.id,
+ },
+ }, max_sequence + 6
+
+ def _get_sequence_values(self):
+ """ Each picking type is created with a sequence. This method returns
+ the sequence values associated to each picking type.
+ """
+ return {
+ 'in_type_id': {
+ 'name': self.name + ' ' + _('Sequence in'),
+ 'prefix': self.code + '/IN/', 'padding': 5,
+ 'company_id': self.company_id.id,
+ },
+ 'out_type_id': {
+ 'name': self.name + ' ' + _('Sequence out'),
+ 'prefix': self.code + '/OUT/', 'padding': 5,
+ 'company_id': self.company_id.id,
+ },
+ 'pack_type_id': {
+ 'name': self.name + ' ' + _('Sequence packing'),
+ 'prefix': self.code + '/PACK/', 'padding': 5,
+ 'company_id': self.company_id.id,
+ },
+ 'pick_type_id': {
+ 'name': self.name + ' ' + _('Sequence picking'),
+ 'prefix': self.code + '/PICK/', 'padding': 5,
+ 'company_id': self.company_id.id,
+ },
+ 'int_type_id': {
+ 'name': self.name + ' ' + _('Sequence internal'),
+ 'prefix': self.code + '/INT/', 'padding': 5,
+ 'company_id': self.company_id.id,
+ },
+ }
+
+ def _format_rulename(self, from_loc, dest_loc, suffix):
+ rulename = '%s: %s' % (self.code, from_loc.name)
+ if dest_loc:
+ rulename += ' → %s' % (dest_loc.name)
+ if suffix:
+ rulename += ' (' + suffix + ')'
+ return rulename
+
+ def _format_routename(self, name=None, route_type=None):
+ if route_type:
+ name = self._get_route_name(route_type)
+ return '%s: %s' % (self.name, name)
+
+ @api.returns('self')
+ def _get_all_routes(self):
+ routes = self.mapped('route_ids') | self.mapped('mto_pull_id').mapped('route_id')
+ routes |= self.env["stock.location.route"].search([('supplied_wh_id', 'in', self.ids)])
+ return routes
+
+ def action_view_all_routes(self):
+ routes = self._get_all_routes()
+ return {
+ 'name': _('Warehouse\'s Routes'),
+ 'domain': [('id', 'in', routes.ids)],
+ 'res_model': 'stock.location.route',
+ 'type': 'ir.actions.act_window',
+ 'view_id': False,
+ 'view_mode': 'tree,form',
+ 'limit': 20,
+ 'context': dict(self._context, default_warehouse_selectable=True, default_warehouse_ids=self.ids)
+ }