odoo/addons/stock/models/stock_quant.py

737 lines
37 KiB
Python
Raw Normal View History

from datetime import datetime
from odoo import api, fields, models
from odoo.tools.float_utils import float_compare, float_round
from odoo.tools.translate import _
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
class Quant(models.Model):
""" Quants are the smallest unit of stock physical instances """
_name = "stock.quant"
_description = "Quants"
name = fields.Char(string='Identifier', compute='_compute_name')
product_id = fields.Many2one(
'product.product', 'Product',
index=True, ondelete="restrict", readonly=True, required=True)
location_id = fields.Many2one(
'stock.location', 'Location',
auto_join=True, index=True, ondelete="restrict", readonly=True, required=True)
qty = fields.Float(
'Quantity',
index=True, readonly=True, required=True,
help="Quantity of products in this quant, in the default unit of measure of the product")
product_uom_id = fields.Many2one(
'product.uom', string='Unit of Measure', related='product_id.uom_id',
readonly=True)
package_id = fields.Many2one(
'stock.quant.package', string='Package',
index=True, readonly=True,
help="The package containing this quant")
packaging_type_id = fields.Many2one(
'product.packaging', string='Type of packaging', related='package_id.packaging_id',
readonly=True, store=True)
reservation_id = fields.Many2one(
'stock.move', 'Reserved for Move',
index=True, readonly=True,
help="The move the quant is reserved for")
lot_id = fields.Many2one(
'stock.production.lot', 'Lot/Serial Number',
index=True, ondelete="restrict", readonly=True)
cost = fields.Float('Unit Cost', group_operator='avg')
owner_id = fields.Many2one(
'res.partner', 'Owner',
index=True, readonly=True,
help="This is the owner of the quant")
create_date = fields.Datetime('Creation Date', readonly=True)
in_date = fields.Datetime('Incoming Date', index=True, readonly=True)
history_ids = fields.Many2many(
'stock.move', 'stock_quant_move_rel', 'quant_id', 'move_id',
string='Moves', copy=False,
help='Moves that operate(d) on this quant')
company_id = fields.Many2one(
'res.company', 'Company',
index=True, readonly=True, required=True,
default=lambda self: self.env['res.company']._company_default_get('stock.quant'),
help="The company to which the quants belong")
inventory_value = fields.Float('Inventory Value', compute='_compute_inventory_value', readonly=True)
# Used for negative quants to reconcile after compensated by a new positive one
propagated_from_id = fields.Many2one(
'stock.quant', 'Linked Quant',
index=True, readonly=True,
help='The negative quant this is coming from')
negative_move_id = fields.Many2one(
'stock.move', 'Move Negative Quant',
readonly=True,
help='If this is a negative quant, this will be the move that caused this negative quant.')
negative_dest_location_id = fields.Many2one(
'stock.location', "Negative Destination Location", related='negative_move_id.location_dest_id',
readonly=True,
help="Technical field used to record the destination location of a move that created a negative quant")
@api.one
def _compute_name(self):
""" Forms complete name of location from parent location to child location. """
self.name = '%s: %s%s' % (self.lot_id.name or self.product_id.code or '', self.qty, self.product_id.uom_id.name)
@api.multi
def _compute_inventory_value(self):
for quant in self:
if quant.company_id != self.env.user.company_id:
# if the company of the quant is different than the current user company, force the company in the context
# then re-do a browse to read the property fields for the good company.
quant = quant.with_context(force_company=quant.company_id.id)
quant.inventory_value = quant.product_id.standard_price * quant.qty
@api.model_cr
def init(self):
self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('stock_quant_product_location_index',))
if not self._cr.fetchone():
self._cr.execute('CREATE INDEX stock_quant_product_location_index ON stock_quant (product_id, location_id, company_id, qty, in_date, reservation_id)')
@api.multi
def unlink(self):
# TDE FIXME: should probably limitate unlink to admin and sudo calls to unlink, because context is not safe
if not self.env.context.get('force_unlink'):
raise UserError(_('Under no circumstances should you delete or change quants yourselves!'))
return super(Quant, self).unlink()
@api.model
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
" Overwrite the read_group in order to sum the function field 'inventory_value' in group by "
# TDE NOTE: WHAAAAT ??? is this because inventory_value is not stored ?
# TDE FIXME: why not storing the inventory_value field ? company_id is required, stored, and should not create issues
res = super(Quant, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
if 'inventory_value' in fields:
for line in res:
if '__domain' in line:
lines = self.search(line['__domain'])
inv_value = 0.0
for line2 in lines:
inv_value += line2.inventory_value
line['inventory_value'] = inv_value
return res
@api.multi
def action_view_quant_history(self):
''' Returns an action that display the history of the quant, which
mean all the stock moves that lead to this quant creation with this
quant quantity. '''
action = self.env.ref('stock', 'stock_move_action').read()[0]
action['domain'] = [('id', 'in', self.mapped('history_ids').ids)]
return action
@api.model
def quants_reserve(self, quants, move, link=False):
''' This function reserves quants for the given move and optionally
given link. If the total of quantity reserved is enough, the move state
is also set to 'assigned'
:param quants: list of tuple(quant browse record or None, qty to reserve). If None is given as first tuple element, the item will be ignored. Negative quants should not be received as argument
:param move: browse record
:param link: browse record (stock.move.operation.link)
'''
# TDE CLEANME: use ids + quantities dict
# TDE CLEANME: check use of sudo
quants_to_reserve_sudo = self.env['stock.quant'].sudo()
reserved_availability = move.reserved_availability
# split quants if needed
for quant, qty in quants:
if qty <= 0.0 or (quant and quant.qty <= 0.0):
raise UserError(_('You can not reserve a negative quantity or a negative quant.'))
if not quant:
continue
quant._quant_split(qty)
quants_to_reserve_sudo |= quant
reserved_availability += quant.qty
# reserve quants
if quants_to_reserve_sudo:
quants_to_reserve_sudo.write({'reservation_id': move.id})
# check if move state needs to be set as 'assigned'
# TDE CLEANME: should be moved as a move model method IMO
rounding = move.product_id.uom_id.rounding
if float_compare(reserved_availability, move.product_qty, precision_rounding=rounding) == 0 and move.state in ('confirmed', 'waiting'):
move.write({'state': 'assigned'})
elif float_compare(reserved_availability, 0, precision_rounding=rounding) > 0 and not move.partially_available:
move.write({'partially_available': True})
@api.model
def quants_move(self, quants, move, location_to, location_from=False, lot_id=False, owner_id=False, src_package_id=False, dest_package_id=False, entire_pack=False):
"""Moves all given stock.quant in the given destination location. Unreserve from current move.
:param quants: list of tuple(browse record(stock.quant) or None, quantity to move)
:param move: browse record (stock.move)
:param location_to: browse record (stock.location) depicting where the quants have to be moved
:param location_from: optional browse record (stock.location) explaining where the quant has to be taken
(may differ from the move source location in case a removal strategy applied).
This parameter is only used to pass to _quant_create_from_move if a negative quant must be created
:param lot_id: ID of the lot that must be set on the quants to move
:param owner_id: ID of the partner that must own the quants to move
:param src_package_id: ID of the package that contains the quants to move
:param dest_package_id: ID of the package that must be set on the moved quant
"""
# TDE CLEANME: use ids + quantities dict
if location_to.usage == 'view':
raise UserError(_('You cannot move to a location of type view %s.') % (location_to.name))
quants_reconcile_sudo = self.env['stock.quant'].sudo()
quants_move_sudo = self.env['stock.quant'].sudo()
check_lot = False
for quant, qty in quants:
if not quant:
#If quant is None, we will create a quant to move (and potentially a negative counterpart too)
quant = self._quant_create_from_move(
qty, move, lot_id=lot_id, owner_id=owner_id, src_package_id=src_package_id, dest_package_id=dest_package_id, force_location_from=location_from, force_location_to=location_to)
check_lot = True
else:
quant._quant_split(qty)
quants_move_sudo |= quant
quants_reconcile_sudo |= quant
if quants_move_sudo:
moves_recompute = quants_move_sudo.filtered(lambda self: self.reservation_id != move).mapped('reservation_id')
quants_move_sudo._quant_update_from_move(move, location_to, dest_package_id, lot_id=lot_id, entire_pack=entire_pack)
moves_recompute.recalculate_move_state()
if location_to.usage == 'internal':
# Do manual search for quant to avoid full table scan (order by id)
self._cr.execute("""
SELECT 0 FROM stock_quant, stock_location WHERE product_id = %s AND stock_location.id = stock_quant.location_id AND
((stock_location.parent_left >= %s AND stock_location.parent_left < %s) OR stock_location.id = %s) AND qty < 0.0 LIMIT 1
""", (move.product_id.id, location_to.parent_left, location_to.parent_right, location_to.id))
if self._cr.fetchone():
quants_reconcile_sudo._quant_reconcile_negative(move)
# In case of serial tracking, check if the product does not exist somewhere internally already
# Checking that a positive quant already exists in an internal location is too restrictive.
# Indeed, if a warehouse is configured with several steps (e.g. "Pick + Pack + Ship") and
# one step is forced (creates a quant of qty = -1.0), it is not possible afterwards to
# correct the inventory unless the product leaves the stock.
picking_type = move.picking_id and move.picking_id.picking_type_id or False
if check_lot and lot_id and move.product_id.tracking == 'serial' and (not picking_type or (picking_type.use_create_lots or picking_type.use_existing_lots)):
other_quants = self.search([('product_id', '=', move.product_id.id), ('lot_id', '=', lot_id),
('qty', '>', 0.0), ('location_id.usage', '=', 'internal')])
if other_quants:
# We raise an error if:
# - the total quantity is strictly larger than 1.0
# - there are more than one negative quant, to avoid situations where the user would
# force the quantity at several steps of the process
if sum(other_quants.mapped('qty')) > 1.0 or len([q for q in other_quants.mapped('qty') if q < 0]) > 1:
lot_name = self.env['stock.production.lot'].browse(lot_id).name
raise UserError(_('The serial number %s is already in stock.') % lot_name + _("Otherwise make sure the right stock/owner is set."))
@api.model
def _quant_create_from_move(self, qty, move, lot_id=False, owner_id=False,
src_package_id=False, dest_package_id=False,
force_location_from=False, force_location_to=False):
'''Create a quant in the destination location and create a negative
quant in the source location if it's an internal location. '''
price_unit = move.get_price_unit()
location = force_location_to or move.location_dest_id
rounding = move.product_id.uom_id.rounding
vals = {
'product_id': move.product_id.id,
'location_id': location.id,
'qty': float_round(qty, precision_rounding=rounding),
'cost': price_unit,
'history_ids': [(4, move.id)],
'in_date': datetime.now().strftime(DEFAULT_SERVER_DATETIME_FORMAT),
'company_id': move.company_id.id,
'lot_id': lot_id,
'owner_id': owner_id,
'package_id': dest_package_id,
}
if move.location_id.usage == 'internal':
# if we were trying to move something from an internal location and reach here (quant creation),
# it means that a negative quant has to be created as well.
negative_vals = vals.copy()
negative_vals['location_id'] = force_location_from and force_location_from.id or move.location_id.id
negative_vals['qty'] = float_round(-qty, precision_rounding=rounding)
negative_vals['cost'] = price_unit
negative_vals['negative_move_id'] = move.id
negative_vals['package_id'] = src_package_id
negative_quant_id = self.sudo().create(negative_vals)
vals.update({'propagated_from_id': negative_quant_id.id})
picking_type = move.picking_id and move.picking_id.picking_type_id or False
if lot_id and move.product_id.tracking == 'serial' and (not picking_type or (picking_type.use_create_lots or picking_type.use_existing_lots)):
if qty != 1.0:
raise UserError(_('You should only receive by the piece with the same serial number'))
# create the quant as superuser, because we want to restrict the creation of quant manually: we should always use this method to create quants
return self.sudo().create(vals)
@api.model
def _quant_create(self, qty, move, lot_id=False, owner_id=False,
src_package_id=False, dest_package_id=False,
force_location_from=False, force_location_to=False):
# FIXME - remove me in master/saas-14
_logger.warning("'_quant_create' has been renamed into '_quant_create_from_move'... Overrides are ignored")
return self._quant_create_from_move(
qty, move, lot_id=lot_id, owner_id=owner_id,
src_package_id=src_package_id, dest_package_id=dest_package_id,
force_location_from=force_location_from, force_location_to=force_location_to)
@api.multi
def _quant_update_from_move(self, move, location_dest_id, dest_package_id, lot_id=False, entire_pack=False):
vals = {
'location_id': location_dest_id.id,
'history_ids': [(4, move.id)],
'reservation_id': False}
if lot_id and any(quant for quant in self if not quant.lot_id.id):
vals['lot_id'] = lot_id
if not entire_pack:
vals.update({'package_id': dest_package_id})
self.write(vals)
@api.multi
def move_quants_write(self, move, location_dest_id, dest_package_id, lot_id=False, entire_pack=False):
# FIXME - remove me in master/saas-14
_logger.warning("'move_quants_write' has been renamed into '_quant_update_from_move'... Overrides are ignored")
return self._quant_update_from_move(move, location_dest_id, dest_package_id, lot_id=lot_id, entire_pack=entire_pack)
@api.one
def _quant_reconcile_negative(self, move):
"""
When new quant arrive in a location, try to reconcile it with
negative quants. If it's possible, apply the cost of the new
quant to the counterpart of the negative quant.
"""
solving_quant = self
quants = self._search_quants_to_reconcile()
product_uom_rounding = self.product_id.uom_id.rounding
for quant_neg, qty in quants:
if not quant_neg or not solving_quant:
continue
quants_to_solve = self.search([('propagated_from_id', '=', quant_neg.id)])
if not quants_to_solve:
continue
solving_qty = qty
solved_quants = self.env['stock.quant'].sudo()
for to_solve_quant in quants_to_solve:
if float_compare(solving_qty, 0, precision_rounding=product_uom_rounding) <= 0:
continue
solved_quants |= to_solve_quant
to_solve_quant._quant_split(min(solving_qty, to_solve_quant.qty))
solving_qty -= min(solving_qty, to_solve_quant.qty)
remaining_solving_quant = solving_quant._quant_split(qty)
remaining_neg_quant = quant_neg._quant_split(-qty)
# if the reconciliation was not complete, we need to link together the remaining parts
if remaining_neg_quant:
remaining_to_solves = self.sudo().search([('propagated_from_id', '=', quant_neg.id), ('id', 'not in', solved_quants.ids)])
if remaining_to_solves:
remaining_to_solves.write({'propagated_from_id': remaining_neg_quant.id})
if solving_quant.propagated_from_id and solved_quants:
solved_quants.write({'propagated_from_id': solving_quant.propagated_from_id.id})
# delete the reconciled quants, as it is replaced by the solved quants
quant_neg.sudo().with_context(force_unlink=True).unlink()
if solved_quants:
# price update + accounting entries adjustments
solved_quants._price_update(solving_quant.cost)
# merge history (and cost?)
solved_quants.write(solving_quant._prepare_history())
solving_quant.with_context(force_unlink=True).unlink()
solving_quant = remaining_solving_quant
def _prepare_history(self):
return {
'history_ids': [(4, history_move.id) for history_move in self.history_ids],
}
@api.multi
def _price_update(self, newprice):
# TDE note: use ACLs instead of sudoing everything
self.sudo().write({'cost': newprice})
@api.multi
def _search_quants_to_reconcile(self):
""" Searches negative quants to reconcile for where the quant to reconcile is put """
dom = ['&', '&', '&', '&',
('qty', '<', 0),
('location_id', 'child_of', self.location_id.id),
('product_id', '=', self.product_id.id),
('owner_id', '=', self.owner_id.id),
# Do not let the quant eat itself, or it will kill its history (e.g. returns / Stock -> Stock)
('id', '!=', self.propagated_from_id.id)]
if self.package_id.id:
dom = ['&'] + dom + [('package_id', '=', self.package_id.id)]
if self.lot_id:
dom = ['&'] + dom + ['|', ('lot_id', '=', False), ('lot_id', '=', self.lot_id.id)]
order = 'lot_id, in_date'
else:
order = 'in_date'
rounding = self.product_id.uom_id.rounding
quants = []
quantity = self.qty
for quant in self.search(dom, order=order):
if float_compare(quantity, abs(quant.qty), precision_rounding=rounding) >= 0:
quants += [(quant, abs(quant.qty))]
quantity -= abs(quant.qty)
elif float_compare(quantity, 0.0, precision_rounding=rounding) != 0:
quants += [(quant, quantity)]
quantity = 0
break
return quants
@api.model
def quants_get_preferred_domain(self, qty, move, ops=False, lot_id=False, domain=None, preferred_domain_list=[]):
''' This function tries to find quants for the given domain and move/ops, by trying to first limit
the choice on the quants that match the first item of preferred_domain_list as well. But if the qty requested is not reached
it tries to find the remaining quantity by looping on the preferred_domain_list (tries with the second item and so on).
Make sure the quants aren't found twice => all the domains of preferred_domain_list should be orthogonal
'''
return self.quants_get_reservation(
qty, move,
pack_operation_id=ops and ops.id or False,
lot_id=lot_id,
company_id=self.env.context.get('company_id', False),
domain=domain,
preferred_domain_list=preferred_domain_list)
def quants_get_reservation(self, qty, move, pack_operation_id=False, lot_id=False, company_id=False, domain=None, preferred_domain_list=None):
''' This function tries to find quants for the given domain and move/ops, by trying to first limit
the choice on the quants that match the first item of preferred_domain_list as well. But if the qty requested is not reached
it tries to find the remaining quantity by looping on the preferred_domain_list (tries with the second item and so on).
Make sure the quants aren't found twice => all the domains of preferred_domain_list should be orthogonal
'''
# TDE FIXME: clean me
reservations = [(None, qty)]
pack_operation = self.env['stock.pack.operation'].browse(pack_operation_id)
location = pack_operation.location_id if pack_operation else move.location_id
# don't look for quants in location that are of type production, supplier or inventory.
if location.usage in ['inventory', 'production', 'supplier']:
return reservations
# return self._Reservation(reserved_quants, qty, qty, move, None)
restrict_lot_id = lot_id if pack_operation else move.restrict_lot_id.id or lot_id
removal_strategy = move.get_removal_strategy()
domain = self._quants_get_reservation_domain(
move,
pack_operation_id=pack_operation_id,
lot_id=lot_id,
company_id=company_id,
initial_domain=domain)
if not restrict_lot_id and not preferred_domain_list:
meta_domains = [[]]
elif restrict_lot_id and not preferred_domain_list:
meta_domains = [[('lot_id', '=', restrict_lot_id)], [('lot_id', '=', False)]]
elif restrict_lot_id and preferred_domain_list:
lot_list = []
no_lot_list = []
for inner_domain in preferred_domain_list:
lot_list.append(inner_domain + [('lot_id', '=', restrict_lot_id)])
no_lot_list.append(inner_domain + [('lot_id', '=', False)])
meta_domains = lot_list + no_lot_list
else:
meta_domains = preferred_domain_list
res_qty = qty
while (float_compare(res_qty, 0, precision_rounding=move.product_id.uom_id.rounding) and meta_domains):
additional_domain = meta_domains.pop(0)
reservations.pop()
new_reservations = self._quants_get_reservation(
res_qty, move,
ops=pack_operation,
domain=domain + additional_domain,
removal_strategy=removal_strategy)
for quant in new_reservations:
if quant[0]:
res_qty -= quant[1]
reservations += new_reservations
return reservations
def _quants_get_reservation_domain(self, move, pack_operation_id=False, lot_id=False, company_id=False, initial_domain=None):
initial_domain = initial_domain if initial_domain is not None else [('qty', '>', 0.0)]
domain = initial_domain + [('product_id', '=', move.product_id.id)]
if pack_operation_id:
pack_operation = self.env['stock.pack.operation'].browse(pack_operation_id)
domain += [('location_id', '=', pack_operation.location_id.id)]
if pack_operation.owner_id:
domain += [('owner_id', '=', pack_operation.owner_id.id)]
if pack_operation.package_id and not pack_operation.product_id:
domain += [('package_id', 'child_of', pack_operation.package_id.id)]
elif pack_operation.package_id and pack_operation.product_id:
domain += [('package_id', '=', pack_operation.package_id.id)]
else:
domain += [('package_id', '=', False)]
else:
domain += [('location_id', 'child_of', move.location_id.id)]
if move.restrict_partner_id:
domain += [('owner_id', '=', move.restrict_partner_id.id)]
if company_id:
domain += [('company_id', '=', company_id)]
else:
domain += [('company_id', '=', move.company_id.id)]
return domain
@api.model
def _quants_removal_get_order(self, removal_strategy=None):
if removal_strategy == 'fifo':
return 'in_date, id'
elif removal_strategy == 'lifo':
return 'in_date desc, id desc'
raise UserError(_('Removal strategy %s not implemented.') % (removal_strategy,))
def _quants_get_reservation(self, quantity, move, ops=False, domain=None, orderby=None, removal_strategy=None):
''' Implementation of removal strategies.
:return: a structure containing an ordered list of tuples: quants and
the quantity to remove from them. A tuple (None, qty)
represents a qty not possible to reserve.
'''
# TDE FIXME: try to clean
if removal_strategy:
order = self._quants_removal_get_order(removal_strategy)
elif orderby:
order = orderby
else:
order = 'in_date'
rounding = move.product_id.uom_id.rounding
domain = domain if domain is not None else [('qty', '>', 0.0)]
res = []
offset = 0
remaining_quantity = quantity
quants = self.search(domain, order=order, limit=10, offset=offset)
while float_compare(remaining_quantity, 0, precision_rounding=rounding) > 0 and quants:
for quant in quants:
if float_compare(remaining_quantity, abs(quant.qty), precision_rounding=rounding) >= 0:
# reserved_quants.append(self._ReservedQuant(quant, abs(quant.qty)))
res += [(quant, abs(quant.qty))]
remaining_quantity -= abs(quant.qty)
elif float_compare(remaining_quantity, 0.0, precision_rounding=rounding) != 0:
# reserved_quants.append(self._ReservedQuant(quant, remaining_quantity))
res += [(quant, remaining_quantity)]
remaining_quantity = 0
offset += 10
quants = self.search(domain, order=order, limit=10, offset=offset)
if float_compare(remaining_quantity, 0, precision_rounding=rounding) > 0:
res.append((None, remaining_quantity))
return res
# Misc tools
# ----------------------------------------------------------------------
def _get_top_level_packages(self, product_to_location):
""" This method searches for as much possible higher level packages that
can be moved as a single operation, given a list of quants to move and
their suggested destination, and returns the list of matching packages. """
top_lvl_packages = self.env['stock.quant.package']
for package in self.mapped('package_id'):
all_in = True
top_package = self.env['stock.quant.package']
while package:
if any(quant not in self for quant in package.get_content()):
all_in = False
if all_in:
destinations = set([product_to_location[product] for product in package.get_content().mapped('product_id')])
if len(destinations) > 1:
all_in = False
if all_in:
top_package = package
package = package.parent_id
else:
package = False
top_lvl_packages |= top_package
return top_lvl_packages
@api.multi
def _get_latest_move(self):
latest_move = self.history_ids[0]
for move in self.history_ids:
if move.date > latest_move.date:
latest_move = move
return latest_move
@api.multi
def _quant_split(self, qty):
self.ensure_one()
rounding = self.product_id.uom_id.rounding
if float_compare(abs(self.qty), abs(qty), precision_rounding=rounding) <= 0: # if quant <= qty in abs, take it entirely
return False
qty_round = float_round(qty, precision_rounding=rounding)
new_qty_round = float_round(self.qty - qty, precision_rounding=rounding)
# Fetch the history_ids manually as it will not do a join with the stock moves then (=> a lot faster)
self._cr.execute("""SELECT move_id FROM stock_quant_move_rel WHERE quant_id = %s""", (self.id,))
res = self._cr.fetchall()
new_quant = self.sudo().copy(default={'qty': new_qty_round, 'history_ids': [(4, x[0]) for x in res]})
self.sudo().write({'qty': qty_round})
return new_quant
class QuantPackage(models.Model):
""" Packages containing quants and/or other packages """
_name = "stock.quant.package"
_description = "Physical Packages"
_parent_name = "parent_id"
_parent_store = True
_parent_order = 'name'
_order = 'parent_left'
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)
parent_id = fields.Many2one(
'stock.quant.package', 'Parent Package',
ondelete='restrict', readonly=True,
help="The package containing this item")
ancestor_ids = fields.One2many('stock.quant.package', string='Ancestors', compute='_compute_ancestor_ids')
children_quant_ids = fields.One2many('stock.quant', string='All Bulk Content', compute='_compute_children_quant_ids')
children_ids = fields.One2many('stock.quant.package', 'parent_id', 'Contained Packages', readonly=True)
parent_left = fields.Integer('Left Parent', index=True)
parent_right = fields.Integer('Right Parent', index=True)
packaging_id = fields.Many2one(
'product.packaging', 'Package Type', index=True,
help="This field should be completed only if everything inside the package share the same product, otherwise it doesn't really makes sense.")
location_id = fields.Many2one(
'stock.location', 'Location', compute='_compute_package_info', search='_search_location',
index=True, readonly=True)
company_id = fields.Many2one(
'res.company', 'Company', compute='_compute_package_info', search='_search_company',
index=True, readonly=True)
owner_id = fields.Many2one(
'res.partner', 'Owner', compute='_compute_package_info', search='_search_owner',
index=True, readonly=True)
@api.one
@api.depends('parent_id', 'children_ids')
def _compute_ancestor_ids(self):
self.ancestor_ids = self.env['stock.quant.package'].search([('id', 'parent_of', self.id)]).ids
@api.multi
@api.depends('parent_id', 'children_ids', 'quant_ids.package_id')
def _compute_children_quant_ids(self):
for package in self:
if package.id:
package.children_quant_ids = self.env['stock.quant'].search([('package_id', 'child_of', package.id)]).ids
@api.depends('quant_ids.package_id', 'quant_ids.location_id', 'quant_ids.company_id', 'quant_ids.owner_id', 'ancestor_ids')
def _compute_package_info(self):
for package in self:
quants = package.children_quant_ids
if quants:
values = quants[0]
else:
values = {'location_id': False, 'company_id': self.env.user.company_id.id, 'owner_id': False}
package.location_id = values['location_id']
package.company_id = values['company_id']
package.owner_id = values['owner_id']
@api.multi
def name_get(self):
return 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:
current = package
name = current.name
while current.parent_id:
name = '%s / %s' % (current.parent_id.name, name)
current = current.parent_id
res[package.id] = name
return res
def _search_location(self, operator, value):
if value:
packs = self.search([('quant_ids.location_id', operator, value)])
else:
packs = self.search([('quant_ids', operator, value)])
if packs:
return [('id', 'parent_of', packs.ids)]
else:
return [('id', '=', False)]
def _search_company(self, operator, value):
if value:
packs = self.search([('quant_ids.company_id', operator, value)])
else:
packs = self.search([('quant_ids', operator, value)])
if packs:
return [('id', 'parent_of', packs.ids)]
else:
return [('id', '=', False)]
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 _check_location_constraint(self):
'''checks that all quants in a package are stored in the same location. This function cannot be used
as a constraint because it needs to be checked on pack operations (they may not call write on the
package)
'''
for pack in self:
parent = pack
while parent.parent_id:
parent = parent.parent_id
locations = parent.get_content().filtered(lambda quant: quant.qty > 0.0).mapped('location_id')
if len(locations) != 1:
raise UserError(_('Everything inside a package should be in the same location'))
return True
@api.multi
def action_view_related_picking(self):
""" Returns an action that display the picking related to this
package (source or destination).
"""
self.ensure_one()
pickings = self.env['stock.picking'].search(['|', ('pack_operation_ids.package_id', '=', self.id), ('pack_operation_ids.result_package_id', '=', self.id)])
action = self.env.ref('stock.action_picking_tree_all').read()[0]
action['domain'] = [('id', 'in', pickings.ids)]
return action
@api.multi
def unpack(self):
for package in self:
# TDE FIXME: why superuser ?
package.mapped('quant_ids').sudo().write({'package_id': package.parent_id.id})
package.mapped('children_ids').write({'parent_id': package.parent_id.id})
return self.env['ir.actions.act_window'].for_xml_id('stock', 'action_package_view')
@api.multi
def view_content_package(self):
action = self.env['ir.actions.act_window'].for_xml_id('stock', 'quantsact')
action['domain'] = [('id', 'in', self._get_contained_quants().ids)]
return action
get_content_package = view_content_package
def _get_contained_quants(self):
return self.env['stock.quant'].search([('package_id', 'child_of', self.ids)])
get_content = _get_contained_quants
def _get_all_products_quantities(self):
'''This function computes the different product quantities for the given package
'''
# TDE CLEANME: probably to move somewhere else, like in pack op
res = {}
for quant in self._get_contained_quants():
if quant.product_id not in res:
res[quant.product_id] = 0
res[quant.product_id] += quant.qty
return res