[ADD]:Added Upstream Patch for stock

This commit is contained in:
Fatemi Lokhandwala 2018-07-06 18:28:06 +05:30
parent f313b7e8e8
commit 227710041f
26 changed files with 1371 additions and 197 deletions

View File

@ -77,7 +77,7 @@ class ProcurementRule(models.Model):
data = self._get_stock_move_values(product_id, product_qty, product_uom, location_id, name, origin, values, group_id)
# Since action_confirm launch following procurement_group we should activate it.
move = self.env['stock.move'].sudo().create(data)
move = self.env['stock.move'].sudo().with_context(force_company=data.get('company_id', False)).create(data)
move._action_confirm()
return True

View File

@ -98,13 +98,13 @@ class Product(models.Model):
domain_move_in = [('product_id', 'in', self.ids)] + domain_move_in_loc
domain_move_out = [('product_id', 'in', self.ids)] + domain_move_out_loc
if lot_id:
if lot_id is not None:
domain_quant += [('lot_id', '=', lot_id)]
if owner_id:
if owner_id is not None:
domain_quant += [('owner_id', '=', owner_id)]
domain_move_in += [('restrict_partner_id', '=', owner_id)]
domain_move_out += [('restrict_partner_id', '=', owner_id)]
if package_id:
if package_id is not None:
domain_quant += [('package_id', '=', package_id)]
if dates_in_the_past:
domain_move_in_done = list(domain_move_in)
@ -221,6 +221,19 @@ class Product(models.Model):
domain + loc_domain + ['!'] + dest_loc_domain if dest_loc_domain else domain + loc_domain
)
def _search_qty_available(self, operator, value):
# In the very specific case we want to retrieve products with stock available, we only need
# to use the quants, not the stock moves. Therefore, we bypass the usual
# '_search_product_quantity' method and call '_search_qty_available_new' instead. This
# allows better performances.
if value == 0.0 and operator == '>' and not ({'from_date', 'to_date'} & set(self.env.context.keys())):
product_ids = self._search_qty_available_new(
operator, value, self.env.context.get('lot_id'), self.env.context.get('owner_id'),
self.env.context.get('package_id')
)
return [('id', 'in', product_ids)]
return self._search_product_quantity(operator, value, 'qty_available')
def _search_virtual_available(self, operator, value):
# TDE FIXME: should probably clean the search methods
return self._search_product_quantity(operator, value, 'virtual_available')
@ -245,24 +258,13 @@ class Product(models.Model):
# TODO: Still optimization possible when searching virtual quantities
ids = []
for product in self.search([]):
for product in self.with_context(prefetch_fields=False).search([]):
if OPERATORS[operator](product[field], value):
ids.append(product.id)
return [('id', 'in', ids)]
def _search_qty_available(self, operator, value):
# TDE FIXME: should probably clean the search methods
if value == 0.0 and operator in ('=', '>=', '<='):
return self._search_product_quantity(operator, value, 'qty_available')
product_ids = self._search_qty_available_new(operator, value, self._context.get('lot_id'), self._context.get('owner_id'), self._context.get('package_id'))
if (value > 0 and operator in ('<=', '<')) or (value < 0 and operator in ('>=', '>')):
# include also unavailable products
domain = self._search_product_quantity(operator, value, 'qty_available')
product_ids += domain[0][2]
return [('id', 'in', product_ids)]
def _search_qty_available_new(self, operator, value, lot_id=False, owner_id=False, package_id=False):
# TDE FIXME: should probably clean the search methods
''' Optimized method which doesn't search on stock.moves, only on stock.quants. '''
product_ids = set()
domain_quant = self._get_domain_locations()[0]
if lot_id:
@ -410,8 +412,11 @@ class ProductTemplate(models.Model):
outgoing_qty = fields.Float(
'Outgoing', compute='_compute_quantities', search='_search_outgoing_qty',
digits=dp.get_precision('Product Unit of Measure'))
location_id = fields.Many2one('stock.location', 'Location')
warehouse_id = fields.Many2one('stock.warehouse', 'Warehouse')
# The goal of these fields is not to be able to search a location_id/warehouse_id but
# to properly make these fields "dummy": only used to put some keys in context from
# the search view in order to influence computed field
location_id = fields.Many2one('stock.location', 'Location', store=False, search=lambda operator, operand, vals: [])
warehouse_id = fields.Many2one('stock.warehouse', 'Warehouse', store=False, search=lambda operator, operand, vals: [])
route_ids = fields.Many2many(
'stock.location.route', 'stock_route_product', 'product_id', 'route_id', 'Routes',
domain=[('product_selectable', '=', True)],
@ -503,7 +508,7 @@ class ProductTemplate(models.Model):
if 'uom_id' in vals:
new_uom = self.env['product.uom'].browse(vals['uom_id'])
updated = self.filtered(lambda template: template.uom_id != new_uom)
done_moves = self.env['stock.move'].search([('product_id', 'in', updated.mapped('product_variant_ids').ids)], limit=1)
done_moves = self.env['stock.move'].search([('product_id', 'in', updated.with_context(active_test=False).mapped('product_variant_ids').ids)], limit=1)
if done_moves:
raise UserError(_("You can not change the unit of measure of a product that has already been used in a done stock move. If you need to change the unit of measure, you may deactivate this product."))
if 'type' in vals and vals['type'] != 'product' and sum(self.mapped('nbr_reordering_rules')) != 0:
@ -578,3 +583,27 @@ class ProductCategory(models.Model):
category = category.parent_id
routes |= category.route_ids
self.total_route_ids = routes
class ProductUoM(models.Model):
_inherit = 'product.uom'
def write(self, values):
# Users can not update the factor if open stock moves are based on it
if 'factor' in values or 'factor_inv' in values or 'category_id' in values:
changed = self.filtered(
lambda u: any(u[f] != values[f] if f in values else False
for f in {'factor', 'factor_inv', 'category_id'}))
if changed:
stock_move_lines = self.env['stock.move.line'].search_count([
('product_uom_id.category_id', 'in', changed.mapped('category_id.id')),
('state', '!=', 'cancel'),
])
if stock_move_lines:
raise UserError(_(
"You cannot change the ratio of this unit of mesure as some"
" products with this UoM have already been moved or are "
"currently reserved."
))
return super(ProductUoM, self).write(values)

View File

@ -28,12 +28,18 @@ class Company(models.Model):
location.sudo().write({'company_id': self.id})
self.write({'internal_transit_location_id': location.id})
warehouses = self.env['stock.warehouse'].search([('partner_id', '=', self.partner_id.id)])
warehouses.mapped('partner_id').with_context(force_company=self.id).write({
'property_stock_customer': location.id,
'property_stock_supplier': location.id,
})
@api.model
def create(self, vals):
company = super(Company, self).create(vals)
# multi-company rules prevents creating warehouse and sub-locations
company.create_transit_location()
# mutli-company rules prevents creating warehouse and sub-locations
self.env['stock.warehouse'].check_access_rights('create')
self.env['stock.warehouse'].sudo().create({'name': company.name, 'code': company.name[:5], 'company_id': company.id, 'partner_id': company.partner_id.id})
company.create_transit_location()
return company

View File

@ -339,7 +339,7 @@ class InventoryLine(models.Model):
# TDE FIXME: necessary ? -> replace by location_id
prodlot_name = fields.Char(
'Serial Number Name',
related='prod_lot_id.name', store=True)
related='prod_lot_id.name', store=True, readonly=True)
company_id = fields.Many2one(
'res.company', 'Company', related='inventory_id.company_id',
index=True, readonly=True, store=True)

View File

@ -91,6 +91,21 @@ class Location(models.Model):
if 'usage' in values and values['usage'] == 'view':
if self.mapped('quant_ids'):
raise UserError(_("This location's usage cannot be changed to view as it contains products."))
if 'usage' in values or 'scrap_location' in values:
modified_locations = self.filtered(
lambda l: any(l[f] != values[f] if f in values else False
for f in {'usage', 'scrap_location'}))
reserved_quantities = self.env['stock.move.line'].search_count([
('location_id', 'in', modified_locations.ids),
('product_qty', '>', 0),
])
if reserved_quantities:
raise UserError(_(
"You cannot change the location type or its use as a scrap"
" location as there are products reserved in this location."
" Please unreserve the products first."
))
return super(Location, self).write(values)
@api.multi

View File

@ -275,14 +275,23 @@ class StockMove(models.Model):
""" This will return the move lines to consider when applying _quantity_done_compute on a stock.move.
In some context, such as MRP, it is necessary to compute quantity_done on filtered sock.move.line."""
self.ensure_one()
return self.move_line_ids
return self.move_line_ids or self.move_line_nosuggest_ids
@api.depends('move_line_ids.qty_done', 'move_line_ids.product_uom_id')
@api.depends('move_line_ids.qty_done', 'move_line_ids.product_uom_id', 'move_line_nosuggest_ids.qty_done')
def _quantity_done_compute(self):
""" This field represents the sum of the move lines `qty_done`. It allows the user to know
if there is still work to do.
We take care of rounding this value at the general decimal precision and not the rounding
of the move's UOM to make sure this value is really close to the real sum, because this
field will be used in `_action_done` in order to know if the move will need a backorder or
an extra move.
"""
for move in self:
quantity_done = 0
for move_line in move._get_move_lines():
# Transform the move_line quantity_done into the move uom.
move.quantity_done += move_line.product_uom_id._compute_quantity(move_line.qty_done, move.product_uom)
quantity_done += move_line.product_uom_id._compute_quantity(move_line.qty_done, move.product_uom, round=False)
move.quantity_done = quantity_done
def _quantity_done_set(self):
quantity_done = self[0].quantity_done # any call to create will invalidate `move.quantity_done`
@ -459,10 +468,10 @@ class StockMove(models.Model):
if propagated_date_field:
current_date = datetime.strptime(move.date_expected, DEFAULT_SERVER_DATETIME_FORMAT)
new_date = datetime.strptime(vals.get(propagated_date_field), DEFAULT_SERVER_DATETIME_FORMAT)
delta = relativedelta.relativedelta(new_date, current_date)
if abs(delta.days) >= move.company_id.propagation_minimum_delta:
delta_days = (new_date - current_date).total_seconds() / 86400
if abs(delta_days) >= move.company_id.propagation_minimum_delta:
old_move_date = datetime.strptime(move.move_dest_ids[0].date_expected, DEFAULT_SERVER_DATETIME_FORMAT)
new_move_date = (old_move_date + relativedelta.relativedelta(days=delta.days or 0)).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
new_move_date = (old_move_date + relativedelta.relativedelta(days=delta_days or 0)).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
propagated_changes_dict['date_expected'] = new_move_date
#For pushed moves as well as for pulled moves, propagate by recursive call of write().
#Note that, for pulled moves we intentionally don't propagate on the procurement.
@ -521,9 +530,19 @@ class StockMove(models.Model):
}
def _do_unreserve(self):
if any(move.state in ('done', 'cancel') for move in self):
raise UserError(_('Cannot unreserve a done move'))
self.mapped('move_line_ids').unlink()
moves_to_unreserve = self.env['stock.move']
for move in self:
if move.state == 'cancel':
# We may have cancelled move in an open picking in a "propagate_cancel" scenario.
continue
if move.state == 'done':
if move.scrapped:
# We may have done move in an open picking in a scrap scenario.
continue
else:
raise UserError(_('Cannot unreserve a done move'))
moves_to_unreserve |= move
moves_to_unreserve.mapped('move_line_ids').unlink()
return True
def _push_apply(self):
@ -618,7 +637,7 @@ class StockMove(models.Model):
# We are using propagate to False in order to not cancel destination moves merged in moves[0]
moves_to_unlink.write({'propagate': False})
moves_to_unlink._action_cancel()
moves_to_unlink.unlink()
moves_to_unlink.sudo().unlink()
return (self | self.env['stock.move'].concat(*moves_to_merge)) - moves_to_unlink
def _get_relevant_state_among_moves(self):
@ -679,7 +698,7 @@ class StockMove(models.Model):
self.product_uom = product.uom_id.id
return {'domain': {'product_uom': [('category_id', '=', product.uom_id.category_id.id)]}}
@api.onchange('date')
@api.onchange('date_expected')
def onchange_date(self):
if self.date_expected:
self.date = self.date_expected
@ -805,7 +824,7 @@ class StockMove(models.Model):
group_id = False
return {
'company_id': self.company_id,
'date_planned': self.date,
'date_planned': self.date_expected,
'move_dest_ids': self,
'group_id': group_id,
'route_ids': self.route_ids,
@ -834,7 +853,12 @@ class StockMove(models.Model):
}
if quantity:
uom_quantity = self.product_id.uom_id._compute_quantity(quantity, self.product_uom, rounding_method='HALF-UP')
vals = dict(vals, product_uom_qty=uom_quantity)
uom_quantity_back_to_product_uom = self.product_uom._compute_quantity(uom_quantity, self.product_id.uom_id, rounding_method='HALF-UP')
rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure')
if float_compare(quantity, uom_quantity_back_to_product_uom, precision_digits=rounding) == 0:
vals = dict(vals, product_uom_qty=uom_quantity)
else:
vals = dict(vals, product_uom_qty=quantity, product_uom_id=self.product_id.uom_id.id)
if reserved_quant:
vals = dict(
vals,
@ -859,18 +883,32 @@ class StockMove(models.Model):
taken_quantity = min(available_quantity, need)
# `taken_quantity` is in the quants unit of measure. There's a possibility that the move's
# unit of measure won't be respected if we blindly reserve this quantity, a common usecase
# is if the move's unit of measure's rounding does not allow fractional reservation. We chose
# to convert `taken_quantity` to the move's unit of measure with a down rounding method and
# then get it back in the quants unit of measure with an half-up rounding_method. This
# way, we'll never reserve more than allowed. We do not apply this logic if
# `available_quantity` is brought by a chained move line. In this case, `_prepare_move_line_vals`
# will take care of changing the UOM to the UOM of the product.
if not strict:
taken_quantity_move_uom = self.product_id.uom_id._compute_quantity(taken_quantity, self.product_uom, rounding_method='DOWN')
taken_quantity = self.product_uom._compute_quantity(taken_quantity_move_uom, self.product_id.uom_id, rounding_method='HALF-UP')
quants = []
if self.product_id.tracking == 'serial':
rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure')
if float_compare(taken_quantity, int(taken_quantity), precision_digits=rounding) != 0:
taken_quantity = 0
try:
quants = self.env['stock.quant']._update_reserved_quantity(
self.product_id, location_id, taken_quantity, lot_id=lot_id,
package_id=package_id, owner_id=owner_id, strict=strict
)
if not float_is_zero(taken_quantity, precision_rounding=self.product_id.uom_id.rounding):
quants = self.env['stock.quant']._update_reserved_quantity(
self.product_id, location_id, taken_quantity, lot_id=lot_id,
package_id=package_id, owner_id=owner_id, strict=strict
)
except UserError:
# If it raises here, it means that the `available_quantity` brought by a done move line
# is not available on the quants itself. This could be the result of an inventory
# adjustment that removed totally of partially `available_quantity`. When this happens, we
# chose to do nothing. This situation could not happen on MTS move, because in this case
# `available_quantity` is directly the quantity on the quants themselves.
taken_quantity = 0
# Find a candidate move line to update or create a new one.
@ -878,7 +916,7 @@ class StockMove(models.Model):
to_update = self.move_line_ids.filtered(lambda m: m.product_id.tracking != 'serial' and
m.location_id.id == reserved_quant.location_id.id and m.lot_id.id == reserved_quant.lot_id.id and m.package_id.id == reserved_quant.package_id.id and m.owner_id.id == reserved_quant.owner_id.id)
if to_update:
to_update[0].with_context(bypass_reservation_update=True).product_uom_qty += self.product_id.uom_id._compute_quantity(quantity, self.product_uom, rounding_method='HALF-UP')
to_update[0].with_context(bypass_reservation_update=True).product_uom_qty += self.product_id.uom_id._compute_quantity(quantity, to_update[0].product_uom_id, rounding_method='HALF-UP')
else:
if self.product_id.tracking == 'serial':
for i in range(0, int(quantity)):
@ -896,7 +934,7 @@ class StockMove(models.Model):
assigned_moves = self.env['stock.move']
partially_available_moves = self.env['stock.move']
for move in self.filtered(lambda m: m.state in ['confirmed', 'waiting', 'partially_available']):
if move.location_id.usage in ('supplier', 'inventory', 'production', 'customer')\
if move.location_id.should_bypass_reservation()\
or move.product_id.type == 'consu':
# create the move line(s) but do not impact quants
if move.product_id.tracking == 'serial' and (move.picking_type_id.use_create_lots or move.picking_type_id.use_existing_lots):
@ -919,12 +957,18 @@ class StockMove(models.Model):
if not move.move_orig_ids:
if move.procure_method == 'make_to_order':
continue
# If we don't need any quantity, consider the move assigned.
need = move.product_qty - move.reserved_availability
if float_is_zero(need, precision_rounding=move.product_id.uom_id.rounding):
assigned_moves |= move
continue
# Reserve new quants and create move lines accordingly.
available_quantity = self.env['stock.quant']._get_available_quantity(move.product_id, move.location_id)
if available_quantity <= 0:
continue
need = move.product_qty - move.reserved_availability
taken_quantity = move._update_reserved_quantity(need, available_quantity, move.location_id, strict=False)
if float_is_zero(taken_quantity, precision_rounding=move.product_id.uom_id.rounding):
continue
if need == taken_quantity:
assigned_moves |= move
else:
@ -980,7 +1024,19 @@ class StockMove(models.Model):
available_move_lines[(move_line.location_id, move_line.lot_id, move_line.result_package_id, move_line.owner_id)] -= move_line.product_qty
for (location_id, lot_id, package_id, owner_id), quantity in available_move_lines.items():
need = move.product_qty - sum(move.move_line_ids.mapped('product_qty'))
taken_quantity = move._update_reserved_quantity(need, quantity, location_id, lot_id, package_id, owner_id)
# `quantity` is what is brought by chained done move lines. We double check
# here this quantity is available on the quants themselves. If not, this
# could be the result of an inventory adjustment that removed totally of
# partially `quantity`. When this happens, we chose to reserve the maximum
# still available. This situation could not happen on MTS move, because in
# this case `quantity` is directly the quantity on the quants themselves.
available_quantity = self.env['stock.quant']._get_available_quantity(
move.product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=True)
if float_is_zero(available_quantity, precision_rounding=move.product_id.uom_id.rounding):
continue
taken_quantity = move._update_reserved_quantity(need, min(quantity, available_quantity), location_id, lot_id, package_id, owner_id)
if float_is_zero(taken_quantity, precision_rounding=move.product_id.uom_id.rounding):
continue
if need - taken_quantity == 0.0:
assigned_moves |= move
break
@ -1031,8 +1087,8 @@ class StockMove(models.Model):
# create the extra moves
extra_move_quantity = float_round(
self.quantity_done - self.product_uom_qty,
precision_rounding=self.product_uom.rounding,
rounding_method ='UP')
precision_rounding=rounding,
rounding_method='HALF-UP')
extra_move_vals = self._prepare_extra_move_vals(extra_move_quantity)
extra_move = self.copy(default=extra_move_vals)
if extra_move.picking_id:
@ -1060,6 +1116,9 @@ class StockMove(models.Model):
break
return extra_move
def _unreserve_initial_demand(self, new_move):
pass
def check_move_bal_qty(self):
for move in self:
if move.move_type == 'in':
@ -1106,8 +1165,10 @@ class StockMove(models.Model):
# Split moves where necessary and move quants
for move in moves_todo:
rounding = move.product_uom.rounding
if float_compare(move.quantity_done, move.product_uom_qty, precision_rounding=rounding) < 0:
# To know whether we need to create a backorder or not, round to the general product's
# decimal precision and not the product's UOM.
rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure')
if float_compare(move.quantity_done, move.product_uom_qty, precision_digits=rounding) < 0:
# Need to do some kind of conversion here
qty_split = move.product_uom._compute_quantity(move.product_uom_qty - move.quantity_done, move.product_id.uom_id, rounding_method='HALF-UP')
new_move = move._split(qty_split)
@ -1120,9 +1181,7 @@ class StockMove(models.Model):
move_line.write({'product_uom_qty': move_line.qty_done})
except UserError:
pass
# If you were already putting stock.move.lots on the next one in the work order, transfer those to the new move
move.move_line_ids.filtered(lambda x: x.qty_done == 0.0).write({'move_id': new_move})
move._unreserve_initial_demand(new_move)
move.move_line_ids._action_done()
# Check the consistency of the result packages; there should be an unique location across
# the contained quants.
@ -1154,15 +1213,17 @@ class StockMove(models.Model):
self.mapped('move_line_ids').unlink()
return super(StockMove, self).unlink()
def _prepare_move_split_vals(self, uom_qty):
def _prepare_move_split_vals(self, qty):
vals = {
'product_uom_qty': uom_qty,
'product_uom_qty': qty,
'procure_method': 'make_to_stock',
'move_dest_ids': [(4, x.id) for x in self.move_dest_ids if x.state not in ('done', 'cancel')],
'move_orig_ids': [(4, x.id) for x in self.move_orig_ids],
'origin_returned_move_id': self.origin_returned_move_id.id,
'price_unit': self.price_unit,
}
if self.env.context.get('force_split_uom_id'):
vals['product_uom'] = self.env.context['force_split_uom_id']
return vals
def _split(self, qty, restrict_partner_id=False):
@ -1181,9 +1242,18 @@ class StockMove(models.Model):
raise UserError(_('You cannot split a draft move. It needs to be confirmed first.'))
if float_is_zero(qty, precision_rounding=self.product_id.uom_id.rounding) or self.product_qty <= qty:
return self.id
# HALF-UP rounding as only rounding errors will be because of propagation of error from default UoM
decimal_precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
# `qty` passed as argument is the quantity to backorder and is always expressed in the
# quants UOM. If we're able to convert back and forth this quantity in the move's and the
# quants UOM, the backordered move can keep the UOM of the move. Else, we'll create is in
# the UOM of the quants.
uom_qty = self.product_id.uom_id._compute_quantity(qty, self.product_uom, rounding_method='HALF-UP')
defaults = self._prepare_move_split_vals(uom_qty)
if float_compare(qty, self.product_uom._compute_quantity(uom_qty, self.product_id.uom_id, rounding_method='HALF-UP'), precision_digits=decimal_precision) == 0:
defaults = self._prepare_move_split_vals(uom_qty)
else:
defaults = self.with_context(force_split_uom_id=self.product_id.uom_id.id)._prepare_move_split_vals(qty)
if restrict_partner_id:
defaults['restrict_partner_id'] = restrict_partner_id
@ -1192,21 +1262,15 @@ class StockMove(models.Model):
if self.env.context.get('source_location_id'):
defaults['location_id'] = self.env.context['source_location_id']
new_move = self.with_context(rounding_method='HALF-UP').copy(defaults)
# ctx = context.copy()
# TDE CLEANME: used only in write in this file, to clean
# ctx['do_not_propagate'] = True
# FIXME: pim fix your crap
self.with_context(do_not_propagate=True, do_not_unreserve=True, rounding_method='HALF-UP').write({'product_uom_qty': self.product_uom_qty - uom_qty})
# if self.move_dest_id and self.propagate and self.move_dest_id.state not in ('done', 'cancel'):
# new_move_prop = self.move_dest_id.split(qty)
# new_move.write({'move_dest_id': new_move_prop})
# returning the first element of list returned by action_confirm is ok because we checked it wouldn't be exploded (and
# thus the result of action_confirm should always be a list of 1 element length)
# In this case we don't merge move since the new move with 0 quantity done will be used for the backorder.
# Update the original `product_qty` of the move. Use the general product's decimal
# precision and not the move's UOM to handle case where the `quantity_done` is not
# compatible with the move's UOM.
new_product_qty = self.product_id.uom_id._compute_quantity(self.product_qty - qty, self.product_uom, round=False)
new_product_qty = float_round(new_product_qty, precision_digits=self.env['decimal.precision'].precision_get('Product Unit of Measure'))
self.with_context(do_not_propagate=True, do_not_unreserve=True, rounding_method='HALF-UP').write({'product_uom_qty': new_product_qty})
new_move = new_move._action_confirm(merge=False)
# TDE FIXME: due to action confirm change
return new_move.id
def _recompute_state(self):

View File

@ -52,7 +52,6 @@ class StockMoveLine(models.Model):
reference = fields.Char(related='move_id.reference', store=True)
in_entire_package = fields.Boolean(compute='_compute_in_entire_package')
@api.one
def _compute_location_description(self):
for operation, operation_sudo in izip(self, self.sudo()):
operation.from_loc = '%s%s' % (operation_sudo.location_id.name, operation.product_id and operation_sudo.package_id.name or '')
@ -141,7 +140,7 @@ class StockMoveLine(models.Model):
help him. This onchange will warn him if he set `qty_done` to a non-supported value.
"""
res = {}
if self.product_id.tracking == 'serial':
if self.qty_done and self.product_id.tracking == 'serial':
if float_compare(self.qty_done, 1.0, precision_rounding=self.move_id.product_id.uom_id.rounding) != 0:
message = _('You can only process 1.0 %s for products with unique serial number.') % self.product_id.uom_id.name
res['warning'] = {'title': _('Warning'), 'message': message}
@ -269,7 +268,7 @@ class StockMoveLine(models.Model):
except UserError:
pass
if new_product_qty != ml.product_qty:
new_product_uom_qty = self.product_id.uom_id._compute_quantity(new_product_qty, self.product_uom_id, rounding_method='HALF-UP')
new_product_uom_qty = ml.product_id.uom_id._compute_quantity(new_product_qty, ml.product_uom_id, rounding_method='HALF-UP')
ml.with_context(bypass_reservation_update=True).product_uom_qty = new_product_uom_qty
# When editing a done move line, the reserved availability of a potential chained move is impacted. Take care of running again `_action_assign` on the concerned moves.
@ -370,6 +369,15 @@ class StockMoveLine(models.Model):
# `action_done` on the next move lines.
ml_to_delete = self.env['stock.move.line']
for ml in self:
# Check here if `ml.qty_done` respects the rounding of `ml.product_uom_id`.
uom_qty = float_round(ml.qty_done, precision_rounding=ml.product_uom_id.rounding, rounding_method='HALF-UP')
precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
qty_done = float_round(ml.qty_done, precision_digits=precision_digits, rounding_method='HALF-UP')
if float_compare(uom_qty, qty_done, precision_digits=precision_digits) != 0:
raise UserError(_('The quantity done for the product "%s" doesn\'t respect the rounding precision \
defined on the unit of measure "%s". Please change the quantity done or the \
rounding precision of your unit of measure.') % (ml.product_id.display_name, ml.product_uom_id.name))
qty_done_float_compared = float_compare(ml.qty_done, 0, precision_rounding=ml.product_uom_id.rounding)
if qty_done_float_compared > 0:
if ml.product_id.tracking != 'none':

View File

@ -320,7 +320,7 @@ class Picking(models.Model):
for picking in self:
if self.env.context.get('force_detailed_view'):
picking.show_operations = True
break
continue
if picking.picking_type_id.show_operations:
if (picking.state == 'draft' and not self.env.context.get('planned_picking')) or picking.state != 'draft':
picking.show_operations = True
@ -669,6 +669,7 @@ class Picking(models.Model):
all_in = True
pack_move_lines = self.move_line_ids.filtered(lambda ml: ml.package_id == package)
keys = ['product_id', 'lot_id']
precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
grouped_quants = {}
for k, g in groupby(sorted(package.quant_ids, key=itemgetter(*keys)), key=itemgetter(*keys)):
@ -677,8 +678,8 @@ class Picking(models.Model):
grouped_ops = {}
for k, g in groupby(sorted(pack_move_lines, key=itemgetter(*keys)), key=itemgetter(*keys)):
grouped_ops[k] = sum(self.env['stock.move.line'].concat(*list(g)).mapped('product_qty'))
if any(grouped_quants.get(key, 0) - grouped_ops.get(key, 0) != 0 for key in grouped_quants) \
or any(grouped_ops.get(key, 0) - grouped_quants.get(key, 0) != 0 for key in grouped_ops):
if any(not float_is_zero(grouped_quants.get(key, 0) - grouped_ops.get(key, 0), precision_digits=precision_digits) for key in grouped_quants) \
or any(not float_is_zero(grouped_ops.get(key, 0) - grouped_quants.get(key, 0), precision_digits=precision_digits) for key in grouped_ops):
all_in = False
return all_in
@ -704,7 +705,8 @@ class Picking(models.Model):
# If no lots when needed, raise error
picking_type = self.picking_type_id
no_quantities_done = all(float_is_zero(move_line.qty_done, precision_rounding=move_line.product_uom_id.rounding) for move_line in self.move_line_ids)
precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
no_quantities_done = all(float_is_zero(move_line.qty_done, precision_digits=precision_digits) for move_line in self.move_line_ids)
no_reserved_quantities = all(float_is_zero(move_line.product_qty, precision_rounding=move_line.product_uom_id.rounding) for move_line in self.move_line_ids)
if no_reserved_quantities and no_quantities_done:
raise UserError(_('You cannot validate a transfer if you have not processed any quantity. You should rather cancel the transfer.'))
@ -760,7 +762,7 @@ class Picking(models.Model):
if self._check_backorder():
return self.action_generate_backorder_wizard()
self.action_done()
return
return True
def action_generate_backorder_wizard(self):
view = self.env.ref('stock.view_backorder_confirmation')

View File

@ -37,6 +37,18 @@ class ProductionLot(models.Model):
raise UserError(_("You are not allowed to create a lot for this picking type"))
return super(ProductionLot, self).create(vals)
@api.multi
def write(self, vals):
if 'product_id' in vals:
move_lines = self.env['stock.move.line'].search([('lot_id', 'in', self.ids)])
if move_lines:
raise UserError(_(
'You are not allowed to change the product linked to a serial or lot number ' +
'if some stock moves have already been created with that number. ' +
'This would lead to inconsistencies in your stock.'
))
return super(ProductionLot, self).write(vals)
@api.one
def _product_qty(self):
# We only care for the quants in internal or transit locations.

View File

@ -81,12 +81,6 @@ class StockQuant(models.Model):
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(_('A serial number should only be linked to a single product.'))
@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.constrains('location_id')
def check_location_id(self):
for quant in self:
@ -111,9 +105,9 @@ class StockQuant(models.Model):
@api.model
def _get_removal_strategy_order(self, removal_strategy):
if removal_strategy == 'fifo':
return 'in_date, id'
return 'in_date ASC NULLS FIRST, id'
elif removal_strategy == 'lifo':
return 'in_date desc, id desc'
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):
@ -136,7 +130,17 @@ class StockQuant(models.Model):
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)
# Copy code of _search for special NULLS FIRST/LAST order
self.sudo(self._uid).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...
return self.browse([x[0] for x in res])
@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):
@ -198,17 +202,16 @@ class StockQuant(models.Model):
quants = self._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=True)
rounding = product_id.uom_id.rounding
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()
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:
@ -255,13 +258,21 @@ class StockQuant(models.Model):
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)
available_quantity = self._get_available_quantity(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict)
if float_compare(quantity, 0, precision_rounding=rounding) > 0 and 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.') % (', '.join(quants.mapped('product_id').mapped('display_name'))))
elif float_compare(quantity, 0, precision_rounding=rounding) < 0 and float_compare(abs(quantity), sum(quants.mapped('reserved_quantity')), precision_rounding=rounding) > 0:
raise UserError(_('It is not possible to unreserve more products of %s than you have in stock.') % (', '.join(quants.mapped('product_id').mapped('display_name'))))
reserved_quants = []
if float_compare(quantity, 0, precision_rounding=rounding) > 0:
# if we want to reserve
available_quantity = self._get_available_quantity(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict)
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.') % (', '.join(quants.mapped('product_id').mapped('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.') % (', '.join(quants.mapped('product_id').mapped('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
@ -450,6 +461,12 @@ class QuantPackage(models.Model):
if move_lines_to_remove:
move_lines_to_remove.write({'result_package_id': False})
else:
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').write({'package_id': False})
def action_view_picking(self):

View File

@ -21,47 +21,55 @@ class MrpStockReport(models.TransientModel):
@api.model
def get_move_lines_upstream(self, move_lines):
res = self.env['stock.move.line']
for move_line in move_lines:
lines_seen = move_lines
lines_todo = list(move_lines)
while lines_todo:
move_line = lines_todo.pop(0)
# if MTO
if move_line.move_id.move_orig_ids:
res |= move_line.move_id.move_orig_ids.mapped('move_line_ids').filtered(
lambda m: m.lot_id.id == move_line.lot_id.id)
lines = move_line.move_id.move_orig_ids.mapped('move_line_ids').filtered(
lambda m: m.lot_id == move_line.lot_id
) - lines_seen
# if MTS
elif move_line.location_id.usage == 'internal':
lines = self.env['stock.move.line'].search([
('product_id', '=', move_line.product_id.id),
('lot_id', '=', move_line.lot_id.id),
('location_dest_id', '=', move_line.location_id.id),
('id', 'not in', lines_seen.ids),
('date', '<', move_line.date),
])
else:
if move_line.location_id.usage == 'internal':
res |= self.env['stock.move.line'].search([
('product_id', '=', move_line.product_id.id),
('lot_id', '=', move_line.lot_id.id),
('location_dest_id', '=', move_line.location_id.id),
('id', '!=', move_line.id),
('date', '<', move_line.date),
])
if res:
res |= self.get_move_lines_upstream(res)
return res
continue
lines_todo += list(lines)
lines_seen |= lines
return lines_seen - move_lines
@api.model
def get_move_lines_downstream(self, move_lines):
res = self.env['stock.move.line']
for move_line in move_lines:
lines_seen = move_lines
lines_todo = list(move_lines)
while lines_todo:
move_line = lines_todo.pop(0)
# if MTO
if move_line.move_id.move_dest_ids:
res |= move_line.move_id.move_dest_ids.mapped('move_line_ids').filtered(
lambda m: m.lot_id.id == move_line.lot_id.id)
lines = move_line.move_id.move_dest_ids.mapped('move_line_ids').filtered(
lambda m: m.lot_id == move_line.lot_id
) - lines_seen
# if MTS
elif move_line.location_dest_id.usage == 'internal':
lines = self.env['stock.move.line'].search([
('product_id', '=', move_line.product_id.id),
('lot_id', '=', move_line.lot_id.id),
('location_id', '=', move_line.location_dest_id.id),
('id', 'not in', lines_seen.ids),
('date', '>', move_line.date),
])
else:
if move_line.location_dest_id.usage == 'internal':
res |= self.env['stock.move.line'].search([
('product_id', '=', move_line.product_id.id),
('lot_id', '=', move_line.lot_id.id),
('location_id', '=', move_line.location_dest_id.id),
('id', '!=', move_line.id),
('date', '>', move_line.date),
])
if res:
res |= self.get_move_lines_downstream(res)
return res
continue
lines_todo += list(lines)
lines_seen |= lines
return lines_seen - move_lines
@api.model
def get_lines(self, line_id=None, **kw):

View File

@ -163,11 +163,14 @@ class Warehouse(models.Model):
# If another partner assigned
if vals.get('partner_id'):
warehouses._update_partner_data(vals['partner_id'], vals.get('company_id'))
res = super(Warehouse, self).write(vals)
# check if we need to delete and recreate route
if vals.get('reception_steps') or vals.get('delivery_steps'):
warehouses._update_routes()
route_vals = warehouses._update_routes()
if route_vals:
self.write(route_vals)
if vals.get('resupply_wh_ids') and not vals.get('resupply_route_ids'):
for warehouse in warehouses:
@ -188,9 +191,10 @@ class Warehouse(models.Model):
ResCompany = self.env['res.company']
if company_id:
transit_loc = ResCompany.browse(company_id).internal_transit_location_id.id
self.env['res.partner'].browse(partner_id).with_context(force_company=company_id).write({'property_stock_customer': transit_loc, 'property_stock_supplier': transit_loc})
else:
transit_loc = ResCompany._company_default_get('stock.warehouse').internal_transit_location_id.id
self.env['res.partner'].browse(partner_id).write({'property_stock_customer': transit_loc, 'property_stock_supplier': transit_loc})
self.env['res.partner'].browse(partner_id).write({'property_stock_customer': transit_loc, 'property_stock_supplier': transit_loc})
def create_sequences_and_picking_types(self):
IrSequenceSudo = self.env['ir.sequence'].sudo()
@ -293,7 +297,7 @@ class Warehouse(models.Model):
reception_route.pull_ids.write({'active': False})
reception_route.push_ids.write({'active': False})
else:
reception_route = self.env['stock.location.route'].create(warehouse._get_reception_delivery_route_values(warehouse.reception_steps))
warehouse.reception_route_id = reception_route = self.env['stock.location.route'].create(warehouse._get_reception_delivery_route_values(warehouse.reception_steps))
# push / procurement (pull) rules for reception
routings = routes_data[warehouse.id][warehouse.reception_steps]
push_rules_list, pull_rules_list = warehouse._get_push_pull_rules_values(
@ -406,7 +410,7 @@ class Warehouse(models.Model):
pull_rules_list = supplier_wh._get_supply_pull_rules_values(
[self.Routing(output_location, transit_location, supplier_wh.out_type_id)],
values={'route_id': inter_wh_route.id, 'propagate_warehouse_id': self.id})
values={'route_id': inter_wh_route.id})
pull_rules_list += self._get_supply_pull_rules_values(
[self.Routing(transit_location, input_location, self.in_type_id)],
values={'route_id': inter_wh_route.id, 'propagate_warehouse_id': supplier_wh.id})
@ -606,11 +610,18 @@ class Warehouse(models.Model):
routes_data = self.get_routes_dict()
# change the default source and destination location and (de)activate operation types
self._update_picking_type()
self._create_or_update_delivery_route(routes_data)
self._create_or_update_reception_route(routes_data)
self._create_or_update_crossdock_route(routes_data)
self._create_or_update_mto_pull(routes_data)
return True
delivery_route = self._create_or_update_delivery_route(routes_data)
reception_route = self._create_or_update_reception_route(routes_data)
crossdock_route = self._create_or_update_crossdock_route(routes_data)
mto_pull = self._create_or_update_mto_pull(routes_data)
return {
'route_ids': [(4, route.id) for route in reception_route | delivery_route | crossdock_route],
'mto_pull_id': mto_pull.id,
'reception_route_id': reception_route.id,
'delivery_route_id': delivery_route.id,
'crossdock_route_id': crossdock_route.id,
}
@api.one
def _update_picking_type(self):

View File

@ -9,8 +9,8 @@
</template>
<template id="report_location_barcode">
<t t-call="web.basic_layout">
<div t-foreach="[docs[x:x+4] for x in xrange(0, len(docs), 4)]" t-as="page_docs" class="page page_stock_location_barcodes">
<t t-call="web.html_container">
<div t-foreach="[docs[x:x+4] for x in xrange(0, len(docs), 4)]" t-as="page_docs" class="page article page_stock_location_barcodes">
<t t-foreach="page_docs" t-as="o">
<t t-if="o.barcode"><t t-set="content" t-value="o.barcode"/></t>
<t t-if="not o.barcode"><t t-set="content" t-value="o.name"/></t>

View File

@ -61,7 +61,7 @@ class ReportStockForecat(models.Model):
LEFT JOIN
stock_location source_location ON sm.location_id = source_location.id
WHERE
sm.state IN ('confirmed','assigned','waiting') and
sm.state IN ('confirmed','partially_available','assigned','waiting') and
source_location.usage != 'internal' and dest_location.usage = 'internal'
GROUP BY sm.date_expected,sm.product_id
UNION ALL
@ -82,7 +82,7 @@ class ReportStockForecat(models.Model):
LEFT JOIN
stock_location dest_location ON sm.location_dest_id = dest_location.id
WHERE
sm.state IN ('confirmed','assigned','waiting') and
sm.state IN ('confirmed','partially_available','assigned','waiting') and
source_location.usage = 'internal' and dest_location.usage != 'internal'
GROUP BY sm.date_expected,sm.product_id)
as MAIN

View File

@ -45,7 +45,7 @@
<field name="view_type">form</field>
<field name="view_mode">graph,pivot</field>
<field name="search_view_id" ref="view_stock_level_forecast_filter"/>
<field name="context">{'search_default_pivot_by':1, 'search_default_graph_by':1, 'search_default_product_tmpl_id': active_id}</field>
<field name="context">{'search_default_product_tmpl_id': active_id}</field>
<field name="view_id" ref="view_stock_level_forecast_pivot"/>
</record>
@ -55,7 +55,7 @@
<field name="view_type">form</field>
<field name="view_mode">graph,pivot</field>
<field name="search_view_id" ref="view_stock_level_forecast_filter"/>
<field name="context">{'search_default_pivot_by':1, 'search_default_graph_by':1, 'search_default_product_id': active_id}</field>
<field name="context">{'search_default_product_id': active_id}</field>
<field name="view_id" ref="view_stock_level_forecast_pivot"/>
</record>
</flectra>

View File

@ -102,14 +102,16 @@
<span t-field="move.product_id.description_picking"/>
</td>
<td>
<span t-if="move.product_qty" t-esc="move.product_qty"/>
<span t-if="move.product_qty" t-field="move.product_qty"/>
<span t-if="not move.product_qty" t-esc="move.product_uom._compute_quantity(move.quantity_done, move.product_id.uom_id, rounding_method='HALF-UP')"/>
<span t-field="move.product_id.uom_id" groups="product.group_uom"/>
</td>
<td>
<t t-if="has_barcode">
<span t-if="move.product_id and move.product_id.barcode">
<img t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('Code128', move.product_id.barcode, 600, 100)" style="width:100%;height:50px"/>
<img t-if="move.product_id.barcode and len(move.product_id.barcode) == 13" t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('EAN13', move.product_id.barcode, 600, 100)" style="width:100%;height:50px"/>
<img t-elif="move.product_id.barcode and len(move.product_id.barcode) == 8" t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('EAN8', move.product_id.barcode, 600, 100)" style="width:100%;height:50px"/>
<img t-else="" t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('Code128', move.product_id.barcode, 600, 100)" style="width:100%;height:50px"/>
</span>
</t>
</td>

View File

@ -9,4 +9,5 @@ from . import test_quant
from . import test_inventory
from . import test_move
from . import test_move2
from . import test_robustness
from . import test_stock_branch

View File

@ -827,6 +827,129 @@ class StockMove(TransactionCase):
self.assertEqual(len(move.move_line_ids), 4.0)
def test_availability_6(self):
""" Check that, in the scenario where a move is in a bigger uom than the uom of the quants
and this uom only allows entire numbers, we don't make a partial reservation when the
quantity available is not enough to reserve the move. Check also that it is not possible
to set `quantity_done` with a value not honouring the UOM's rounding.
"""
# on the dozen uom, set the rounding set 1.0
self.uom_dozen.rounding = 1
# 6 units are available in stock
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 6.0)
# the move should not be reserved
move = self.env['stock.move'].create({
'name': 'test_availability_6',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_dozen.id,
'product_uom_qty': 1,
})
move._action_confirm()
move._action_assign()
self.assertEqual(move.state, 'confirmed')
# the quants should be left untouched
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 6.0)
# make 8 units available, the move should again not be reservabale
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 2.0)
move._action_assign()
self.assertEqual(move.state, 'confirmed')
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 8.0)
# make 12 units available, this time the move should be reservable
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 4.0)
move._action_assign()
self.assertEqual(move.state, 'assigned')
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 0.0)
# Check it isn't possible to set any value to quantity_done
with self.assertRaises(UserError):
move.quantity_done = 0.1
move._action_done()
with self.assertRaises(UserError):
move.quantity_done = 1.1
move._action_done()
with self.assertRaises(UserError):
move.quantity_done = 0.9
move._action_done()
move.quantity_done = 1
move._action_done()
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.customer_location), 12.0)
def test_availability_7(self):
""" Check that, in the scenario where a move is in a bigger uom than the uom of the quants
and this uom only allows entire numbers, we only reserve quantity honouring the uom's
rounding even if the quantity is set across multiple quants.
"""
# on the dozen uom, set the rounding set 1.0
self.uom_dozen.rounding = 1
# make 12 quants of 1
for i in range(1, 13):
lot_id = self.env['stock.production.lot'].create({
'name': 'lot%s' % str(i),
'product_id': self.product2.id,
})
self.env['stock.quant']._update_available_quantity(self.product2, self.stock_location, 1.0, lot_id=lot_id)
# the move should be reserved
move = self.env['stock.move'].create({
'name': 'test_availability_7',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product2.id,
'product_uom': self.uom_dozen.id,
'product_uom_qty': 1,
})
move._action_confirm()
move._action_assign()
self.assertEqual(move.state, 'assigned')
self.assertEqual(len(move.move_line_ids.mapped('product_uom_id')), 1)
self.assertEqual(move.move_line_ids.mapped('product_uom_id'), self.uom_unit)
for move_line in move.move_line_ids:
move_line.qty_done = 1
move._action_done()
self.assertEqual(move.product_uom_qty, 1)
self.assertEqual(move.product_uom.id, self.uom_dozen.id)
self.assertEqual(move.state, 'done')
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product2, self.customer_location), 12.0)
self.assertEqual(len(self.env['stock.quant']._gather(self.product2, self.customer_location)), 12)
def test_availability_8(self):
""" Test the assignment mechanism when the product quantity is decreased on a partially
reserved stock move.
"""
# make some stock
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 3.0)
self.assertAlmostEqual(self.product1.qty_available, 3.0)
move_partial = self.env['stock.move'].create({
'name': 'test_partial',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 5.0,
})
move_partial._action_confirm()
move_partial._action_assign()
self.assertAlmostEqual(self.product1.virtual_available, -2.0)
self.assertEqual(move_partial.state, 'partially_available')
move_partial.product_uom_qty = 3.0
move_partial._action_assign()
self.assertEqual(move_partial.state, 'assigned')
def test_unreserve_1(self):
""" Check that unreserving a stock move sets the products reserved as available and
set the state back to confirmed.
@ -1022,6 +1145,47 @@ class StockMove(TransactionCase):
for quant in quants:
self.assertEqual(quant.reserved_quantity, 0)
def test_unreserve_6(self):
""" In a situation with a negative and a positive quant, reserve and unreserve.
"""
q1 = self.env['stock.quant'].create({
'product_id': self.product1.id,
'location_id': self.stock_location.id,
'quantity': -10,
'reserved_quantity': 0,
})
q2 = self.env['stock.quant'].create({
'product_id': self.product1.id,
'location_id': self.stock_location.id,
'quantity': 30.0,
'reserved_quantity': 10.0,
})
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 10.0)
move1 = self.env['stock.move'].create({
'name': 'test_unreserve_6',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 10.0,
})
move1._action_confirm()
move1._action_assign()
self.assertEqual(move1.state, 'assigned')
self.assertEqual(len(move1.move_line_ids), 1)
self.assertEqual(move1.move_line_ids.product_qty, 10)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 0.0)
self.assertEqual(q2.reserved_quantity, 20)
move1._do_unreserve()
self.assertEqual(move1.state, 'confirmed')
self.assertEqual(len(move1.move_line_ids), 0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 10.0)
self.assertEqual(q2.reserved_quantity, 10)
def test_link_assign_1(self):
""" Test the assignment mechanism when two chained stock moves try to move one unit of an
untracked product.
@ -1356,6 +1520,319 @@ class StockMove(TransactionCase):
self.assertEqual(move_stock_stock_1.state, 'assigned')
self.assertEqual(move_stock_stock_2.state, 'waiting')
def test_link_assign_7(self):
# on the dozen uom, set the rounding set 1.0
self.uom_dozen.rounding = 1
# 6 units are available in stock
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 6.0)
# create pickings and moves for a pick -> pack mto scenario
picking_stock_pack = self.env['stock.picking'].create({
'location_id': self.stock_location.id,
'location_dest_id': self.pack_location.id,
'picking_type_id': self.env.ref('stock.picking_type_internal').id,
})
move_stock_pack = self.env['stock.move'].create({
'name': 'test_link_assign_7',
'location_id': self.stock_location.id,
'location_dest_id': self.pack_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_dozen.id,
'product_uom_qty': 1.0,
'picking_id': picking_stock_pack.id,
})
picking_pack_cust = self.env['stock.picking'].create({
'location_id': self.pack_location.id,
'location_dest_id': self.customer_location.id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
})
move_pack_cust = self.env['stock.move'].create({
'name': 'test_link_assign_7',
'location_id': self.pack_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_dozen.id,
'product_uom_qty': 1.0,
'picking_id': picking_pack_cust.id,
})
move_stock_pack.write({'move_dest_ids': [(4, move_pack_cust.id, 0)]})
move_pack_cust.write({'move_orig_ids': [(4, move_stock_pack.id, 0)]})
(move_stock_pack + move_pack_cust)._action_confirm()
# the pick should not be reservable because of the rounding of the dozen
move_stock_pack._action_assign()
self.assertEqual(move_stock_pack.state, 'confirmed')
move_pack_cust._action_assign()
self.assertEqual(move_pack_cust.state, 'waiting')
# move the 6 units by adding an unreserved move line
move_stock_pack.write({'move_line_ids': [(0, 0, {
'product_id': self.product1.id,
'product_uom_id': self.uom_unit.id,
'qty_done': 6,
'product_uom_qty': 0,
'lot_id': False,
'package_id': False,
'result_package_id': False,
'location_id': move_stock_pack.location_id.id,
'location_dest_id': move_stock_pack.location_dest_id.id,
'picking_id': picking_stock_pack.id,
})]})
# the quantity done on the move should not respect the rounding of the move line
self.assertEqual(move_stock_pack.quantity_done, 0.5)
# create the backorder in the uom of the quants
backorder_wizard_dict = picking_stock_pack.button_validate()
backorder_wizard = self.env[backorder_wizard_dict['res_model']].browse(backorder_wizard_dict['res_id'])
backorder_wizard.process()
self.assertEqual(move_stock_pack.state, 'done')
self.assertEqual(move_stock_pack.quantity_done, 0.5)
self.assertEqual(move_stock_pack.product_uom_qty, 0.5)
# the second move should not be reservable because of the rounding on the dozen
move_pack_cust._action_assign()
self.assertEqual(move_pack_cust.state, 'partially_available')
move_line_pack_cust = move_pack_cust.move_line_ids
self.assertEqual(move_line_pack_cust.product_uom_qty, 6)
self.assertEqual(move_line_pack_cust.product_uom_id.id, self.uom_unit.id)
# move a dozen on the backorder to see how we handle the extra move
backorder = self.env['stock.picking'].search([('backorder_id', '=', picking_stock_pack.id)])
backorder.move_lines.write({'move_line_ids': [(0, 0, {
'product_id': self.product1.id,
'product_uom_id': self.uom_dozen.id,
'qty_done': 1,
'product_uom_qty': 0,
'lot_id': False,
'package_id': False,
'result_package_id': False,
'location_id': backorder.location_id.id,
'location_dest_id': backorder.location_dest_id.id,
'picking_id': backorder.id,
})]})
overprocessed_wizard_dict = backorder.button_validate()
overprocessed_wizard = self.env[overprocessed_wizard_dict['res_model']].browse(overprocessed_wizard_dict['res_id'])
overprocessed_wizard.action_confirm()
backorder_move = backorder.move_lines
self.assertEqual(backorder_move.state, 'done')
self.assertEqual(backorder_move.quantity_done, 12.0)
self.assertEqual(backorder_move.product_uom_qty, 12.0)
self.assertEqual(backorder_move.product_uom, self.uom_unit)
# the second move should now be reservable
move_pack_cust._action_assign()
self.assertEqual(move_pack_cust.state, 'assigned')
self.assertEqual(move_line_pack_cust.product_uom_qty, 12)
self.assertEqual(move_line_pack_cust.product_uom_id.id, self.uom_unit.id)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, move_stock_pack.location_dest_id), 6)
def test_link_assign_8(self):
""" Set the rounding of the dozen to 1.0, create a chain of two move for a dozen, the product
concerned is tracked by serial number. Check that the flow is ok.
"""
# on the dozen uom, set the rounding set 1.0
self.uom_dozen.rounding = 1
# 6 units are available in stock
for i in range(1, 13):
lot_id = self.env['stock.production.lot'].create({
'name': 'lot%s' % str(i),
'product_id': self.product2.id,
})
self.env['stock.quant']._update_available_quantity(self.product2, self.stock_location, 1.0, lot_id=lot_id)
# create pickings and moves for a pick -> pack mto scenario
picking_stock_pack = self.env['stock.picking'].create({
'location_id': self.stock_location.id,
'location_dest_id': self.pack_location.id,
'picking_type_id': self.env.ref('stock.picking_type_internal').id,
})
move_stock_pack = self.env['stock.move'].create({
'name': 'test_link_assign_7',
'location_id': self.stock_location.id,
'location_dest_id': self.pack_location.id,
'product_id': self.product2.id,
'product_uom': self.uom_dozen.id,
'product_uom_qty': 1.0,
'picking_id': picking_stock_pack.id,
})
picking_pack_cust = self.env['stock.picking'].create({
'location_id': self.pack_location.id,
'location_dest_id': self.customer_location.id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
})
move_pack_cust = self.env['stock.move'].create({
'name': 'test_link_assign_7',
'location_id': self.pack_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product2.id,
'product_uom': self.uom_dozen.id,
'product_uom_qty': 1.0,
'picking_id': picking_pack_cust.id,
})
move_stock_pack.write({'move_dest_ids': [(4, move_pack_cust.id, 0)]})
move_pack_cust.write({'move_orig_ids': [(4, move_stock_pack.id, 0)]})
(move_stock_pack + move_pack_cust)._action_confirm()
move_stock_pack._action_assign()
self.assertEqual(move_stock_pack.state, 'assigned')
move_pack_cust._action_assign()
self.assertEqual(move_pack_cust.state, 'waiting')
for ml in move_stock_pack.move_line_ids:
ml.qty_done = 1
picking_stock_pack.button_validate()
self.assertEqual(move_pack_cust.state, 'assigned')
for ml in move_pack_cust.move_line_ids:
self.assertEqual(ml.product_uom_qty, 1)
self.assertEqual(ml.product_uom_id.id, self.uom_unit.id)
self.assertTrue(bool(ml.lot_id.id))
def test_link_assign_9(self):
""" Create an uom "3 units" which is 3 times the units but without rounding. Create 3
quants in stock and two chained moves. The first move will bring the 3 quants but the
second only validate 2 and create a backorder for the last one. Check that the reservation
is correctly cleared up for the last one.
"""
uom_3units = self.env['product.uom'].create({
'name': '3 units',
'category_id': self.uom_unit.category_id.id,
'factor_inv': 3,
'rounding': 1,
})
for i in range(1, 4):
lot_id = self.env['stock.production.lot'].create({
'name': 'lot%s' % str(i),
'product_id': self.product2.id,
})
self.env['stock.quant']._update_available_quantity(self.product2, self.stock_location, 1.0, lot_id=lot_id)
picking_stock_pack = self.env['stock.picking'].create({
'location_id': self.stock_location.id,
'location_dest_id': self.pack_location.id,
'picking_type_id': self.env.ref('stock.picking_type_internal').id,
})
move_stock_pack = self.env['stock.move'].create({
'name': 'test_link_assign_9',
'location_id': self.stock_location.id,
'location_dest_id': self.pack_location.id,
'product_id': self.product2.id,
'product_uom': uom_3units.id,
'product_uom_qty': 1.0,
'picking_id': picking_stock_pack.id,
})
picking_pack_cust = self.env['stock.picking'].create({
'location_id': self.pack_location.id,
'location_dest_id': self.customer_location.id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
})
move_pack_cust = self.env['stock.move'].create({
'name': 'test_link_assign_0',
'location_id': self.pack_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product2.id,
'product_uom': uom_3units.id,
'product_uom_qty': 1.0,
'picking_id': picking_pack_cust.id,
})
move_stock_pack.write({'move_dest_ids': [(4, move_pack_cust.id, 0)]})
move_pack_cust.write({'move_orig_ids': [(4, move_stock_pack.id, 0)]})
(move_stock_pack + move_pack_cust)._action_confirm()
picking_stock_pack.action_assign()
for ml in picking_stock_pack.move_lines.move_line_ids:
ml.qty_done = 1
picking_stock_pack.button_validate()
self.assertEqual(picking_pack_cust.state, 'assigned')
for ml in picking_pack_cust.move_lines.move_line_ids:
if ml.lot_id.name != 'lot3':
ml.qty_done = 1
res_dict_for_back_order = picking_pack_cust.button_validate()
backorder_wizard = self.env[(res_dict_for_back_order.get('res_model'))].browse(res_dict_for_back_order.get('res_id'))
backorder_wizard.process()
backorder = self.env['stock.picking'].search([('backorder_id', '=', picking_pack_cust.id)])
backordered_move = backorder.move_lines
# due to the rounding, the backordered quantity is 0.999 ; we shoudln't be able to reserve
# 0.999 on a tracked by serial number quant
backordered_move._action_assign()
self.assertEqual(backordered_move.reserved_availability, 0)
# force the serial number and validate
lot3 = self.env['stock.production.lot'].search([('name', '=', "lot3")])
backorder.write({'move_line_ids': [(0, 0, {
'product_id': self.product2.id,
'product_uom_id': self.uom_unit.id,
'qty_done': 1,
'product_uom_qty': 0,
'lot_id': lot3.id,
'package_id': False,
'result_package_id': False,
'location_id': backordered_move.location_id.id,
'location_dest_id': backordered_move.location_dest_id.id,
'move_id': backordered_move.id,
})]})
overprocessed_wizard = backorder.button_validate()
overprocessed_wizard = self.env['stock.overprocessed.transfer'].browse(overprocessed_wizard['res_id'])
overprocessed_wizard.action_confirm()
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product2, self.customer_location), 3)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product2, self.pack_location), 0)
def test_link_assign_10(self):
""" Test the assignment mechanism with partial availability.
"""
# make some stock:
# stock location: 2.0
# pack location: -1.0
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 2.0)
self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 1.0)
move_out = self.env['stock.move'].create({
'name': 'test_link_assign_out',
'location_id': self.pack_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
})
move_out._action_confirm()
move_out._action_assign()
move_out.quantity_done = 1.0
move_out._action_done()
self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.pack_location)), 1.0)
move_stock_pack = self.env['stock.move'].create({
'name': 'test_link_assign_1_1',
'location_id': self.stock_location.id,
'location_dest_id': self.pack_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 2.0,
})
move_pack_cust = self.env['stock.move'].create({
'name': 'test_link_assign_1_2',
'location_id': self.pack_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 2.0,
})
move_stock_pack.write({'move_dest_ids': [(4, move_pack_cust.id, 0)]})
move_pack_cust.write({'move_orig_ids': [(4, move_stock_pack.id, 0)]})
(move_stock_pack + move_pack_cust)._action_confirm()
move_stock_pack._action_assign()
move_stock_pack.quantity_done = 2.0
move_stock_pack._action_done()
self.assertEqual(len(move_pack_cust.move_line_ids), 1)
self.assertAlmostEqual(move_pack_cust.reserved_availability, 1.0)
self.assertEqual(move_pack_cust.state, 'partially_available')
def test_use_unreserved_move_line_1(self):
""" Test that validating a stock move linked to an untracked product reserved by another one
correctly unreserves the other one.
@ -1784,7 +2261,7 @@ class StockMove(TransactionCase):
self.assertEqual(move_line.product_qty, 5)
move_line.qty_done = 5.0
self.assertEqual(move_line.product_qty, 5) # don't change reservation
move_line.with_context(debug=True).lot_id = lot1
move_line.lot_id = lot1
self.assertEqual(move_line.product_qty, 5) # don't change reservation when assgning a lot now
move1._action_done()
@ -2598,9 +3075,9 @@ class StockMove(TransactionCase):
return picking
def test_immediate_validate_5(self):
""" Create a picking and simulates validate button effect.
Test that tracked products can be received without specifying a serial
number when the picking type is configured that way.
""" In a receipt with a single tracked by serial numbers move, clicking on validate without
filling any quantities nor lot should open an UserError except if the picking type is
configured to allow otherwise.
"""
picking_type_id = self.env.ref('stock.picking_type_in')
product_id = self.product2
@ -3166,39 +3643,6 @@ class StockMove(TransactionCase):
self.assertEqual(self.product1.qty_available, 5.0)
self.assertEqual(self.product1.with_context(company_owned=True).qty_available, 10.0)
def test_split_1(self):
""" When we split a move line and having one without quantity done, we want to keep reservation
on the new one as it has not been unreserved during the copy.
"""
move1 = self.env['stock.move'].create({
'name': 'test_split_1',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 10.0,
'picking_type_id': self.env.ref('stock.picking_type_in').id,
})
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 10)
move1._action_confirm()
move1._action_assign()
move_line = move1.move_line_ids
default = {'product_uom_qty': 3,
'qty_done': 3}
move_line.copy(default=default)
move_line.with_context(bypass_reservation_update=True).write({'product_uom_qty': 7, 'qty_done': 0})
move1._action_done()
new_move = self.env['stock.move'].search([('name', '=', 'test_split_1'), ('state', '=', 'confirmed')])
self.assertEqual(move1.move_line_ids.product_uom_qty, 0.0)
self.assertEqual(move1.move_line_ids.qty_done, 3.0)
self.assertEqual(new_move.move_line_ids.product_uom_qty, 7.0)
self.assertEqual(new_move.move_line_ids.qty_done, 0.0)
def test_edit_initial_demand_1(self):
""" Increase initial demand once everything is reserved and check if
the existing move_line is updated.
@ -3410,4 +3854,3 @@ class StockMove(TransactionCase):
picking.button_validate()
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.customer_location), 2)

View File

@ -185,6 +185,73 @@ class TestPickShip(TestStockCommon):
# the client picking should not be assigned anymore, as we returned partially what we took
self.assertEqual(picking_client.state, 'confirmed')
def test_mto_moves_return_return(self):
picking_pick, picking_client = self.create_pick_ship()
stock_location = self.env['stock.location'].browse(self.stock_location)
lot = self.env['stock.production.lot'].create({
'product_id': self.productA.id,
'name': '123456789'
})
self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 10.0, lot_id=lot)
picking_pick.action_assign()
picking_pick.move_lines[0].move_line_ids[0].qty_done = 10.0
picking_pick.button_validate()
self.assertEqual(picking_pick.state, 'done')
self.assertEqual(picking_client.state, 'assigned')
# return this picking
stock_return_picking = self.env['stock.return.picking']\
.with_context(active_ids=picking_pick.ids, active_id=picking_pick.ids[0])\
.create({})
stock_return_picking.product_return_moves.quantity = 10.0
stock_return_picking_action = stock_return_picking.create_returns()
return_pick = self.env['stock.picking'].browse(stock_return_picking_action['res_id'])
return_pick.move_lines[0].move_line_ids[0].write({
'qty_done': 10.0,
'lot_id': lot.id,
})
return_pick.button_validate()
# return this return of this picking
stock_return_picking = self.env['stock.return.picking']\
.with_context(active_id=return_pick.id)\
.create({})
stock_return_picking.product_return_moves.quantity = 10.0
stock_return_picking_action = stock_return_picking.create_returns()
return_return_pick = self.env['stock.picking'].browse(stock_return_picking_action['res_id'])
return_return_pick.move_lines[0].move_line_ids[0].write({
'qty_done': 10.0,
'lot_id': lot.id,
})
return_return_pick.button_validate()
# test computation of traceability
vals = {
'line_id': 1,
'model_name': 'stock.move.line',
'level': 11,
'parent_quant': False,
}
lines = self.env['stock.traceability.report'].get_lines(
model_id=return_return_pick.move_line_ids[0].id,
stream='upstream',
**vals
)
self.assertEqual(
[l.get('res_id') for l in lines],
[return_return_pick.id, return_pick.id, picking_pick.id],
"Upstream computation from return of return worked"
)
lines = self.env['stock.traceability.report'].get_lines(
model_id=picking_pick.move_line_ids[0].id,
stream='downstream',
**vals
)
self.assertEqual(
[l.get('res_id') for l in lines],
[picking_pick.id, return_pick.id, return_return_pick.id],
"Downstream computation from original picking worked"
)
def test_mto_resupply_cancel_ship(self):
""" This test simulates a pick pack ship with a resupply route
set. Pick and pack are validated, ship is cancelled. This test
@ -963,6 +1030,82 @@ class TestSinglePicking(TestStockCommon):
self.assertEqual(sum(move1.move_line_ids.mapped('qty_done')), 2.0)
self.assertEqual(move1.state, 'done')
def test_extra_move_4(self):
""" Create a picking with similar moves (created after
confirmation). Action done should propagate all the extra
quantity and only merge extra moves in their original moves.
"""
delivery = self.env['stock.picking'].create({
'location_id': self.stock_location,
'location_dest_id': self.customer_location,
'partner_id': self.partner_delta_id,
'picking_type_id': self.picking_type_out,
})
self.MoveObj.create({
'name': self.productA.name,
'product_id': self.productA.id,
'product_uom_qty': 5,
'quantity_done': 10,
'product_uom': self.productA.uom_id.id,
'picking_id': delivery.id,
'location_id': self.stock_location,
'location_dest_id': self.customer_location,
})
stock_location = self.env['stock.location'].browse(self.stock_location)
self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 5)
delivery.action_confirm()
delivery.action_assign()
delivery.write({
'move_lines': [(0, 0, {
'name': self.productA.name,
'product_id': self.productA.id,
'product_uom_qty': 0,
'quantity_done': 10,
'state': 'assigned',
'product_uom': self.productA.uom_id.id,
'picking_id': delivery.id,
'location_id': self.stock_location,
'location_dest_id': self.customer_location,
})]
})
delivery.action_done()
self.assertEqual(len(delivery.move_lines), 2, 'Move should not be merged together')
for move in delivery.move_lines:
self.assertEqual(move.quantity_done, move.product_uom_qty, 'Initial demand should be equals to quantity done')
def test_extra_move_5(self):
""" Create a picking a move that is problematic with
rounding (5.95 - 5.5 = 0.4500000000000002). Ensure that
initial demand is corrct afer action_done and backoder
are not created.
"""
delivery = self.env['stock.picking'].create({
'location_id': self.stock_location,
'location_dest_id': self.customer_location,
'partner_id': self.partner_delta_id,
'picking_type_id': self.picking_type_out,
})
self.MoveObj.create({
'name': self.productA.name,
'product_id': self.productA.id,
'product_uom_qty': 5.5,
'quantity_done': 5.95,
'product_uom': self.productA.uom_id.id,
'picking_id': delivery.id,
'location_id': self.stock_location,
'location_dest_id': self.customer_location,
})
stock_location = self.env['stock.location'].browse(self.stock_location)
self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 5.5)
delivery.action_confirm()
delivery.action_assign()
delivery.action_done()
self.assertEqual(delivery.move_lines.product_uom_qty, 5.95, 'Move initial demand should be 5.95')
back_order = self.env['stock.picking'].search([('backorder_id', '=', delivery.id)])
self.assertFalse(back_order, 'There should be no back order')
def test_recheck_availability_1(self):
""" Check the good behavior of check availability. I create a DO for 2 unit with
only one in stock. After the first check availability, I should have 1 reserved

View File

@ -629,7 +629,24 @@ class StockQuant(TransactionCase):
})
quantity, in_date = self.env['stock.quant']._update_available_quantity(product1, stock_location, 1.0)
self.assertEqual(quantity, 1)
self.assertEqual(in_date, None)
self.assertNotEqual(in_date, None)
def test_in_date_1b(self):
stock_location = self.env.ref('stock.stock_location_stock')
product1 = self.env['product.product'].create({
'name': 'Product A',
'type': 'product',
})
self.env['stock.quant'].create({
'product_id': product1.id,
'location_id': stock_location.id,
'quantity': 1.0,
})
quantity, in_date = self.env['stock.quant']._update_available_quantity(product1, stock_location, 2.0)
self.assertEqual(quantity, 3)
self.assertNotEqual(in_date, None)
def test_in_date_2(self):
""" Check that an incoming date is correctly set when updating the quantity of a tracked
@ -707,6 +724,34 @@ class StockQuant(TransactionCase):
# Removal strategy is LIFO, so lot1 should be received as it was received later.
self.assertEqual(quants[0][0].lot_id.id, lot1.id)
def test_in_date_4b(self):
""" Check for LIFO and max with/without in_date that it handles the LIFO NULLS LAST well
"""
stock_location = self.env.ref('stock.stock_location_stock')
stock_location1 = self.env.ref('stock.stock_location_components')
stock_location2 = self.env.ref('stock.stock_location_14')
lifo_strategy = self.env['product.removal'].search([('method', '=', 'lifo')])
stock_location.removal_strategy_id = lifo_strategy
product1 = self.env['product.product'].create({
'name': 'Product A',
'type': 'product',
'tracking': 'serial',
})
self.env['stock.quant'].create({
'product_id': product1.id,
'location_id': stock_location1.id,
'quantity': 1.0,
})
in_date_location2 = datetime.now()
self.env['stock.quant']._update_available_quantity(product1, stock_location2, 1.0, in_date=in_date_location2)
quants = self.env['stock.quant']._update_reserved_quantity(product1, stock_location, 1)
# Removal strategy is LIFO, so the one with date is the most recent one and should be selected
self.assertEqual(quants[0][0].location_id.id, stock_location2.id)
def test_in_date_5(self):
""" Receive the same lot at different times, once they're in the same location, the quants
are merged and only the earliest incoming date is kept.

View File

@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
from flectra.exceptions import UserError
from flectra.tests.common import TransactionCase
class TestRobustness(TransactionCase):
def setUp(self):
super(TestRobustness, self).setUp()
self.stock_location = self.env.ref('stock.stock_location_stock')
self.customer_location = self.env.ref('stock.stock_location_customers')
self.uom_unit = self.env.ref('product.product_uom_unit')
self.uom_dozen = self.env.ref('product.product_uom_dozen')
self.product1 = self.env['product.product'].create({
'name': 'Product A',
'type': 'product',
'categ_id': self.env.ref('product.product_category_all').id,
})
def test_uom_factor(self):
""" Changing the factor of a unit of measure shouldn't be allowed while
quantities are reserved, else the existing move lines won't be consistent
with the `reserved_quantity` on quants.
"""
# make some stock
self.env['stock.quant']._update_available_quantity(
self.product1,
self.stock_location,
12,
)
# reserve a dozen
move1 = self.env['stock.move'].create({
'name': 'test_uom_rounding',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_dozen.id,
'product_uom_qty': 1,
})
move1._action_confirm()
move1._action_assign()
self.assertEqual(move1.state, 'assigned')
quant = self.env['stock.quant']._gather(
self.product1,
self.stock_location,
)
# assert the reservation
self.assertEqual(quant.reserved_quantity, 12)
self.assertEqual(move1.product_qty, 12)
# change the factor
with self.assertRaises(UserError):
with self.cr.savepoint():
move1.product_uom.factor = 0.05
# assert the reservation
self.assertEqual(quant.reserved_quantity, 12)
self.assertEqual(move1.state, 'assigned')
self.assertEqual(move1.product_qty, 12)
# unreserve
move1._do_unreserve()
def test_location_usage(self):
""" Changing the usage of a location shouldn't be allowed while
quantities are reserved, else the existing move lines won't be
consistent with the `reserved_quantity` on the quants.
"""
# change stock usage
self.stock_location.scrap_location = True
# make some stock
self.env['stock.quant']._update_available_quantity(
self.product1,
self.stock_location,
1,
)
# reserve a unit
move1 = self.env['stock.move'].create({
'name': 'test_location_archive',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 1,
})
move1._action_confirm()
move1._action_assign()
self.assertEqual(move1.state, 'assigned')
quant = self.env['stock.quant']._gather(
self.product1,
self.stock_location,
)
# assert the reservation
self.assertEqual(quant.reserved_quantity, 0) # reservation is bypassed in scrap location
self.assertEqual(move1.product_qty, 1)
# change the stock usage
with self.assertRaises(UserError):
with self.cr.savepoint():
self.stock_location.scrap_location = False
# unreserve
move1._do_unreserve()
def test_package_unpack(self):
""" Unpack a package that contains quants with a reservation
should also remove the package on the reserved move lines.
"""
package = self.env['stock.quant.package'].create({
'name': 'Shell Helix HX7 10W30',
})
self.env['stock.quant']._update_available_quantity(
self.product1,
self.stock_location,
10,
package_id=package
)
# reserve a dozen
move1 = self.env['stock.move'].create({
'name': 'test_uom_rounding',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 10,
})
move1._action_confirm()
move1._action_assign()
move1.result_package_id = False
package.unpack()
# unreserve
move1._do_unreserve()
self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 1)
self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location, package_id=package)), 0)
self.assertEqual(self.env['stock.quant']._gather(self.product1, self.stock_location).reserved_quantity, 0)

View File

@ -229,6 +229,215 @@ class TestWarehouse(TestStockCommon):
quant = self.env['stock.quant'].search([('product_id', '=', productA.id), ('location_id', '=', location_loss.id)])
self.assertEqual(len(quant), 1)
def test_resupply_route(self):
""" Simulate a resupply chain between warehouses.
Stock -> transit -> Dist. -> transit -> Shop -> Customer
Create the move from Shop to Customer and ensure that all the pull
rules are triggered in order to complete the move chain to Stock.
"""
warehouse_stock = self.env['stock.warehouse'].create({
'name': 'Stock.',
'code': 'STK',
})
warehouse_distribution = self.env['stock.warehouse'].create({
'name': 'Dist.',
'code': 'DIST',
'default_resupply_wh_id': warehouse_stock.id,
'resupply_wh_ids': [(6, 0, [warehouse_stock.id])]
})
warehouse_shop = self.env['stock.warehouse'].create({
'name': 'Shop',
'code': 'SHOP',
'default_resupply_wh_id': warehouse_distribution.id,
'resupply_wh_ids': [(6, 0, [warehouse_distribution.id])]
})
route_stock_to_dist = warehouse_distribution.resupply_route_ids
route_dist_to_shop = warehouse_shop.resupply_route_ids
# Change the procure_method on the pull rules between dist and shop
# warehouses. Since mto and resupply routes are both on product it will
# select one randomly between them and if it select the resupply it is
# 'make to stock' and it will not create the picking between stock and
# dist warehouses.
route_dist_to_shop.pull_ids.write({'procure_method': 'make_to_order'})
product = self.env['product.product'].create({
'name': 'Fakir',
'type': 'product',
'route_ids': [(4, route_id) for route_id in [route_stock_to_dist.id, route_dist_to_shop.id, self.env.ref('stock.route_warehouse0_mto').id]],
})
picking_out = self.env['stock.picking'].create({
'partner_id': self.env.ref('base.res_partner_2').id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
'location_id': warehouse_shop.lot_stock_id.id,
'location_dest_id': self.env.ref('stock.stock_location_customers').id,
})
self.env['stock.move'].create({
'name': product.name,
'product_id': product.id,
'product_uom_qty': 1,
'product_uom': product.uom_id.id,
'picking_id': picking_out.id,
'location_id': warehouse_shop.lot_stock_id.id,
'location_dest_id': self.env.ref('stock.stock_location_customers').id,
'warehouse_id': warehouse_shop.id,
'procure_method': 'make_to_order',
})
picking_out.action_confirm()
moves = self.env['stock.move'].search([('product_id', '=', product.id)])
# Shop/Stock -> Customer
# Transit -> Shop/Stock
# Dist/Stock -> Transit
# Transit -> Dist/Stock
# Stock/Stock -> Transit
self.assertEqual(len(moves), 5, 'Invalid moves number.')
self.assertTrue(self.env['stock.move'].search([('location_id', '=', warehouse_stock.lot_stock_id.id)]))
self.assertTrue(self.env['stock.move'].search([('location_dest_id', '=', warehouse_distribution.lot_stock_id.id)]))
self.assertTrue(self.env['stock.move'].search([('location_id', '=', warehouse_distribution.lot_stock_id.id)]))
self.assertTrue(self.env['stock.move'].search([('location_dest_id', '=', warehouse_shop.lot_stock_id.id)]))
self.assertTrue(self.env['stock.move'].search([('location_id', '=', warehouse_shop.lot_stock_id.id)]))
def test_mutiple_resupply_warehouse(self):
""" Simulate the following situation:
- 2 shops with stock are resupply by 2 distinct warehouses
- Shop Namur is resupply by the warehouse stock Namur
- Shop Wavre is resupply by the warehouse stock Wavre
- Simulate 2 moves for the same product but in different shop.
This test ensure that the move are supplied by the correct distribution
warehouse.
"""
customer_location = self.env.ref('stock.stock_location_customers')
warehouse_distribution_wavre = self.env['stock.warehouse'].create({
'name': 'Stock Wavre.',
'code': 'WV',
})
warehouse_shop_wavre = self.env['stock.warehouse'].create({
'name': 'Shop Wavre',
'code': 'SHWV',
'default_resupply_wh_id': warehouse_distribution_wavre.id,
'resupply_wh_ids': [(6, 0, [warehouse_distribution_wavre.id])]
})
warehouse_distribution_namur = self.env['stock.warehouse'].create({
'name': 'Stock Namur.',
'code': 'NM',
})
warehouse_shop_namur = self.env['stock.warehouse'].create({
'name': 'Shop Namur',
'code': 'SHNM',
'default_resupply_wh_id': warehouse_distribution_namur.id,
'resupply_wh_ids': [(6, 0, [warehouse_distribution_namur.id])]
})
route_shop_namur = warehouse_shop_namur.resupply_route_ids
route_shop_wavre = warehouse_shop_wavre.resupply_route_ids
# The product contains the 2 resupply routes.
product = self.env['product.product'].create({
'name': 'Fakir',
'type': 'product',
'route_ids': [(4, route_id) for route_id in [route_shop_namur.id, route_shop_wavre.id, self.env.ref('stock.route_warehouse0_mto').id]],
})
# Add 1 quant in each distribution warehouse.
self.env['stock.quant']._update_available_quantity(product, warehouse_distribution_wavre.lot_stock_id, 1.0)
self.env['stock.quant']._update_available_quantity(product, warehouse_distribution_namur.lot_stock_id, 1.0)
# Create the move for the shop Namur. Should create a resupply from
# distribution warehouse Namur.
picking_out_namur = self.env['stock.picking'].create({
'partner_id': self.env.ref('base.res_partner_2').id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
'location_id': warehouse_shop_namur.lot_stock_id.id,
'location_dest_id': customer_location.id,
})
self.env['stock.move'].create({
'name': product.name,
'product_id': product.id,
'product_uom_qty': 1,
'product_uom': product.uom_id.id,
'picking_id': picking_out_namur.id,
'location_id': warehouse_shop_namur.lot_stock_id.id,
'location_dest_id': customer_location.id,
'warehouse_id': warehouse_shop_namur.id,
'procure_method': 'make_to_order',
})
picking_out_namur.action_confirm()
# Validate the picking
# Dist. warehouse Namur -> transit Location -> Shop Namur
picking_stock_transit = self.env['stock.picking'].search([('location_id', '=', warehouse_distribution_namur.lot_stock_id.id)])
self.assertTrue(picking_stock_transit)
picking_stock_transit.action_assign()
picking_stock_transit.move_lines[0].quantity_done = 1.0
picking_stock_transit.action_done()
picking_transit_shop_namur = self.env['stock.picking'].search([('location_dest_id', '=', warehouse_shop_namur.lot_stock_id.id)])
self.assertTrue(picking_transit_shop_namur)
picking_transit_shop_namur.action_assign()
picking_transit_shop_namur.move_lines[0].quantity_done = 1.0
picking_transit_shop_namur.action_done()
picking_out_namur.action_assign()
picking_out_namur.move_lines[0].quantity_done = 1.0
picking_out_namur.action_done()
# Check that the correct quantity has been provided to customer
self.assertEqual(self.env['stock.quant']._gather(product, customer_location).quantity, 1)
# Ensure there still no quants in distribution warehouse
self.assertEqual(len(self.env['stock.quant']._gather(product, warehouse_distribution_namur.lot_stock_id)), 0)
# Create the move for the shop Wavre. Should create a resupply from
# distribution warehouse Wavre.
picking_out_wavre = self.env['stock.picking'].create({
'partner_id': self.env.ref('base.res_partner_2').id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
'location_id': warehouse_shop_wavre.lot_stock_id.id,
'location_dest_id': customer_location.id,
})
self.env['stock.move'].create({
'name': product.name,
'product_id': product.id,
'product_uom_qty': 1,
'product_uom': product.uom_id.id,
'picking_id': picking_out_wavre.id,
'location_id': warehouse_shop_wavre.lot_stock_id.id,
'location_dest_id': customer_location.id,
'warehouse_id': warehouse_shop_wavre.id,
'procure_method': 'make_to_order',
})
picking_out_wavre.action_confirm()
# Validate the picking
# Dist. warehouse Wavre -> transit Location -> Shop Wavre
picking_stock_transit = self.env['stock.picking'].search([('location_id', '=', warehouse_distribution_wavre.lot_stock_id.id)])
self.assertTrue(picking_stock_transit)
picking_stock_transit.action_assign()
picking_stock_transit.move_lines[0].quantity_done = 1.0
picking_stock_transit.action_done()
picking_transit_shop_wavre = self.env['stock.picking'].search([('location_dest_id', '=', warehouse_shop_wavre.lot_stock_id.id)])
self.assertTrue(picking_transit_shop_wavre)
picking_transit_shop_wavre.action_assign()
picking_transit_shop_wavre.move_lines[0].quantity_done = 1.0
picking_transit_shop_wavre.action_done()
picking_out_wavre.action_assign()
picking_out_wavre.move_lines[0].quantity_done = 1.0
picking_out_wavre.action_done()
# Check that the correct quantity has been provided to customer
self.assertEqual(self.env['stock.quant']._gather(product, customer_location).quantity, 2)
# Ensure there still no quants in distribution warehouse
self.assertEqual(len(self.env['stock.quant']._gather(product, warehouse_distribution_wavre.lot_stock_id)), 0)
class TestResupply(TestStockCommon):
def setUp(self):

View File

@ -78,6 +78,7 @@
<group string="Applied On">
<field name="location_id"/>
<field name="warehouse_id" groups="base.group_no_one"/>
<field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/>
</group>
<group string="Creates">
<field name="location_src_id" attrs="{'required': [('action', '=', 'move')], 'invisible':[('action', '!=', 'move')]}" domain="[('usage','!=','view')]"/>

View File

@ -273,7 +273,7 @@
<field name="push_ids" colspan="4" nolabel="1"/>
</group>
<group string="Procurement Rules" colspan="4" >
<field name="pull_ids" colspan="4" nolabel="1"/>
<field name="pull_ids" colspan="4" nolabel="1" context="{'default_company_id': company_id}"/>
</group>
</sheet>
</form>
@ -285,6 +285,7 @@
<field name="model">stock.location.route</field>
<field name="arch" type="xml">
<search string="Route">
<field name="name"/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
</search>
</field>

View File

@ -25,6 +25,8 @@
<field name="state" widget="statusbar"/>
</header>
<sheet>
<field name="in_entire_package" invisible="1"/>
<field name="picking_id" invisible="1"/>
<group>
<group>
<field name="date"/>
@ -44,7 +46,8 @@
<field name="qty_done"/>
<field name="product_uom_id" options="{'no_create': True}" string="Unit of Measure" groups="product.group_uom"/>
</div>
<field name="lot_id" string="Lot/Serial Number" groups="stock.group_production_lot"/>
<field name="lot_id" attrs="{'readonly': [('in_entire_package', '=', True)]}" domain="[('product_id', '=', product_id)]" groups="stock.group_production_lot" context="{'default_product_id': product_id, 'active_picking_id': picking_id}"/>
<field name="lot_name" attrs="{'readonly': [('in_entire_package', '=', True)]}" groups="stock.group_production_lot"/>
<field name="package_id" string="Source Package" groups="product.group_stock_packaging"/>
<field name="result_package_id" string="Destination Package" groups="stock.group_tracking_lot"/>
<field name="owner_id" string="Owner" groups="stock.group_tracking_owner"/>
@ -86,6 +89,7 @@
<field name="model">stock.move.line</field>
<field name="arch" type="xml">
<kanban class="o_kanban_mobile">
<field name="in_entire_package"/>
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_card oe_kanban_global_click">
@ -93,6 +97,8 @@
<field name="picking_id"/>
<div class="row">
<div class="col-xs-6">
<field name="lot_id" invisible="not context.get('show_lots_m2o')" domain="[('product_id', '=', product_id)]" groups="stock.group_production_lot" context="{'default_product_id': product_id, 'active_picking_id': picking_id}"/>
<field name="lot_name" invisible="not context.get('show_lots_text')" groups="stock.group_production_lot"/>
<field name="qty_done" string="Quantity Done"/>
<field name="product_uom_id" string="Unit of Measure" groups="product.group_uom"/>
</div>

View File

@ -77,9 +77,9 @@
<kanban class="o_kanban_mobile">
<field name="name"/>
<field name="product_id"/>
<field name="date"/>
<field name="priority"/>
<field name="state"/>
<field name="show_details_visible"/>
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_global_click">
@ -93,7 +93,11 @@
<field name="product_id"/>
</div>
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_left"/>
<div class="oe_kanban_bottom_left">
<button name="action_show_details" string="Register lots, packs, location"
class="o_icon_button fa fa-list" type="object"
attrs="{'invisible': [('show_details_visible', '=', False)]}" options='{"warn": true}'/>
</div>
<div class="oe_kanban_bottom_right">
<span><field name="product_uom_qty"/></span>
</div>