diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/stock/models/stock_quant.py | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/stock/models/stock_quant.py')
| -rw-r--r-- | addons/stock/models/stock_quant.py | 745 |
1 files changed, 745 insertions, 0 deletions
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 |
