374 lines
17 KiB
Python
374 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from psycopg2 import OperationalError
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from odoo.tools import pycompat
|
|
from odoo.osv import expression
|
|
|
|
|
|
class StockQuant(models.Model):
|
|
_name = 'stock.quant'
|
|
_description = 'Quants'
|
|
|
|
product_id = fields.Many2one(
|
|
'product.product', 'Product',
|
|
ondelete='restrict', readonly=True, required=True)
|
|
product_uom_id = fields.Many2one(
|
|
'product.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',
|
|
auto_join=True, ondelete='restrict', readonly=True, required=True)
|
|
lot_id = fields.Many2one(
|
|
'stock.production.lot', 'Lot/Serial Number',
|
|
ondelete='restrict', readonly=True)
|
|
package_id = fields.Many2one(
|
|
'stock.quant.package', 'Package',
|
|
help='The package containing this quant', readonly=True, ondelete='restrict')
|
|
owner_id = fields.Many2one(
|
|
'res.partner', 'Owner',
|
|
help='This is the owner of the quant', readonly=True)
|
|
quantity = fields.Float(
|
|
'Quantity',
|
|
help='Quantity of products in this quant, in the default unit of measure of the product',
|
|
readonly=True, required=True, oldname='qty')
|
|
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)
|
|
in_date = fields.Datetime('Incoming Date', readonly=True)
|
|
|
|
@api.multi
|
|
@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.multi
|
|
@api.constrains('quantity')
|
|
def check_quantity(self):
|
|
for quant in self:
|
|
if quant.quantity > 1 and quant.lot_id and quant.product_id.tracking == 'serial':
|
|
raise ValidationError(_('A serial number should only be linked to a single product.'))
|
|
|
|
@api.multi
|
|
@api.constrains('in_date', 'lot_id')
|
|
def check_in_date(self):
|
|
for quant in self:
|
|
if quant.in_date and not quant.lot_id:
|
|
raise ValidationError(_('An incoming date cannot be set to an untracked product.'))
|
|
|
|
@api.one
|
|
def _compute_name(self):
|
|
self.name = '%s: %s%s' % (self.lot_id.name or self.product_id.code or '', self.quantity, self.product_id.uom_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, id'
|
|
elif removal_strategy == 'lifo':
|
|
return 'in_date desc, 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):
|
|
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)], 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 and lot_id.id or 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])
|
|
|
|
return self.search(domain, order=removal_strategy_order)
|
|
|
|
@api.model
|
|
def _get_available_quantity(self, product_id, location_id, lot_id=None, package_id=None, owner_id=None, strict=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)
|
|
return sum(quants.mapped('quantity')) - sum(quants.mapped('reserved_quantity'))
|
|
|
|
@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:
|
|
incoming_dates = quants.mapped('in_date') # `mapped` already filtered out falsy items
|
|
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():
|
|
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,
|
|
})
|
|
# cleanup empty quants
|
|
if quant.quantity == 0 and quant.reserved_quantity == 0:
|
|
quant.unlink()
|
|
break
|
|
except OperationalError as e:
|
|
if e.pgcode == '55P03': # could not obtain the lock
|
|
continue
|
|
else:
|
|
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), 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()
|
|
quants = self._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict)
|
|
|
|
quants_quantity = sum(quants.mapped('quantity'))
|
|
available_quantity = quants_quantity - sum(quants.mapped('reserved_quantity'))
|
|
if quantity > 0 and quantity > available_quantity:
|
|
raise UserError(_('It is not possible to reserve more products than you have in stock.'))
|
|
elif quantity < 0 and abs(quantity) > sum(quants.mapped('reserved_quantity')):
|
|
raise UserError(_('It is not possible to unreserve more products than you have in stock.'))
|
|
|
|
reserved_quants = []
|
|
for quant in quants:
|
|
if quantity > 0:
|
|
max_quantity_on_quant = quant.quantity - quant.reserved_quantity
|
|
if max_quantity_on_quant <= 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 quantity == 0 or available_quantity == 0:
|
|
break
|
|
return reserved_quants
|
|
|
|
|
|
class QuantPackage(models.Model):
|
|
""" Packages containing quants and/or other packages """
|
|
_name = "stock.quant.package"
|
|
_description = "Physical 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)
|
|
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)
|
|
move_line_ids = fields.One2many('stock.move.line', 'result_package_id')
|
|
current_picking_move_line_ids = fields.One2many('stock.move.line', compute="_compute_current_picking_info")
|
|
current_picking_id = fields.Boolean(compute="_compute_current_picking_info")
|
|
|
|
@api.depends('quant_ids.package_id', 'quant_ids.location_id', 'quant_ids.company_id', 'quant_ids.owner_id')
|
|
def _compute_package_info(self):
|
|
for package in self:
|
|
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 list(pycompat.items(self._compute_complete_name()))
|
|
|
|
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 _compute_current_picking_info(self):
|
|
picking_id = self.env.context.get('picking_id')
|
|
if picking_id:
|
|
self.current_picking_move_line_ids = self.move_line_ids.filtered(lambda move_line: move_line.picking_id.id == picking_id)
|
|
self.current_picking_id = True
|
|
else:
|
|
self.current_picking_move_line_ids = False
|
|
self.current_picking_id = False
|
|
|
|
|
|
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:
|
|
locations = pack.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 unpack(self):
|
|
for package in self:
|
|
move_lines_to_remove = self.move_line_ids.filtered(lambda move_line: move_line.state != 'done')
|
|
if move_lines_to_remove:
|
|
move_lines_to_remove.write({'result_package_id': False})
|
|
else:
|
|
package.mapped('quant_ids').write({'package_id': False})
|
|
|
|
def action_view_picking(self):
|
|
action = self.env.ref('stock.action_picking_tree_all').read()[0]
|
|
pickings = self.env['stock.move.line'].search([('result_package_id', 'in', self.ids)]).mapped('picking_id')
|
|
action['domain'] = [('id', 'in', pickings.ids)]
|
|
return action
|
|
|
|
@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
|