737 lines
37 KiB
Python
737 lines
37 KiB
Python
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
|