diff --git a/addons/stock/__manifest__.py b/addons/stock/__manifest__.py index 58f16ed5757..46c9e4a8ce9 100644 --- a/addons/stock/__manifest__.py +++ b/addons/stock/__manifest__.py @@ -54,7 +54,6 @@ 'views/procurement_views.xml', 'views/stock_warehouse_views.xml', 'views/stock_move_views.xml', - 'views/stock_pack_operation_views.xml', 'views/product_views.xml', 'views/stock_config_settings_views.xml', diff --git a/addons/stock/data/stock_demo.yml b/addons/stock/data/stock_demo.yml index 13d82adf10b..0263730991a 100644 --- a/addons/stock/data/stock_demo.yml +++ b/addons/stock/data/stock_demo.yml @@ -91,12 +91,12 @@ picking_type_id: stock.picking_type_out location_id: stock.stock_location_stock location_dest_id: stock.stock_location_customers - pack_operation_ids: - - product_id: product.product_product_6 - product_qty: 100 - product_uom_id: product.product_uom_unit - location_id: stock.stock_location_stock - location_dest_id: stock.stock_location_customers + pack_operation_ids: + - product_id: product.product_product_6 + qty_done: 100 + product_uom_id: product.product_uom_unit + location_id: stock.stock_location_stock + location_dest_id: stock.stock_location_customers - !record {model: stock.picking, id: outgoing_shipment_main_warehouse2}: picking_type_id: stock.picking_type_out @@ -231,12 +231,12 @@ location_id: stock.stock_location_suppliers location_dest_id: stock.stock_location_shop0 state: done - pack_operation_ids: - - product_id: product.product_delivery_01 - product_qty: 100 - product_uom_id: product.product_uom_unit - location_id: stock.stock_location_suppliers - location_dest_id: stock.stock_location_shop0 + pack_operation_ids: + - product_id: product.product_delivery_01 + qty_done: 100 + product_uom_id: product.product_uom_unit + location_id: stock.stock_location_suppliers + location_dest_id: stock.stock_location_shop0 - !record {model: stock.picking, id: incomming_chicago_warehouse2}: picking_type_id: chi_picking_type_in @@ -296,12 +296,12 @@ picking_type_id: chi_picking_type_out location_id: stock.stock_location_shop0 location_dest_id: stock.stock_location_customers - pack_operation_ids: - - product_id: product.product_product_6 - product_qty: 100 - product_uom_id: product.product_uom_unit - location_id: stock.stock_location_shop0 - location_dest_id: stock.stock_location_customers + pack_operation_ids: + - product_id: product.product_product_6 + qty_done: 100 + product_uom_id: product.product_uom_unit + location_id: stock.stock_location_shop0 + location_dest_id: stock.stock_location_customers - !record {model: stock.picking, id: outgoing_chicago_warehouse2}: picking_type_id: chi_picking_type_out diff --git a/addons/stock/models/procurement.py b/addons/stock/models/procurement.py index 011fe65ea8c..8ac7fc638d7 100644 --- a/addons/stock/models/procurement.py +++ b/addons/stock/models/procurement.py @@ -77,7 +77,7 @@ class ProcurementOrder(models.Model): def propagate_cancels(self): # set the context for the propagation of the procurement cancellation # TDE FIXME: was in cancel, moved here for consistency - cancel_moves = self.with_context(cancel_procurement=True).filtered(lambda order: order.rule_id.action == 'move').mapped('move_ids') + cancel_moves = self.filtered(lambda order: order.rule_id.action == 'move').mapped('move_ids').filtered(lambda m: m.state not in ('cancel', 'done')) if cancel_moves: cancel_moves.action_cancel() return self.search([('move_dest_id', 'in', cancel_moves.filtered(lambda move: move.propagate).ids)]) @@ -161,7 +161,7 @@ class ProcurementOrder(models.Model): 'partner_id': self.rule_id.partner_address_id.id or (self.group_id and self.group_id.partner_id.id) or False, 'location_id': self.rule_id.location_src_id.id, 'location_dest_id': self.location_id.id, - 'move_dest_id': self.move_dest_id and self.move_dest_id.id or False, + 'move_dest_ids': self.move_dest_id and [(4, self.move_dest_id.id)] or False, 'procurement_id': self.id, 'rule_id': self.rule_id.id, 'procure_method': self.rule_id.procure_method, diff --git a/addons/stock/models/stock_location.py b/addons/stock/models/stock_location.py index 62c447360f4..b96421dfd34 100644 --- a/addons/stock/models/stock_location.py +++ b/addons/stock/models/stock_location.py @@ -117,6 +117,10 @@ class Location(models.Model): ('view_location_id.parent_left', '<=', self.parent_left), ('view_location_id.parent_right', '>=', self.parent_left)], limit=1) + def should_impact_quants(self): + self.ensure_one() + return False if self.usage in ('supplier', 'inventory', 'production', 'customer') else True + class Route(models.Model): _name = 'stock.location.route' @@ -223,7 +227,7 @@ class PushedFlow(models.Model): else: new_move_vals = self._prepare_move_copy_values(move, new_date) new_move = move.copy(new_move_vals) - move.write({'move_dest_id': new_move.id}) + move.write({'move_dest_ids': [(4, new_move.id)]}) new_move.action_confirm() def _prepare_move_copy_values(self, move_to_copy, new_date): diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 46d68d62f3a..7d785e00358 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -3,6 +3,8 @@ from datetime import datetime from dateutil import relativedelta +from itertools import groupby +from operator import itemgetter from odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp @@ -80,31 +82,34 @@ class StockMove(models.Model): 'res.partner', 'Destination Address ', states={'done': [('readonly', True)]}, help="Optional address where goods are to be delivered, specifically used for allotment") - move_dest_id = fields.Many2one( - 'stock.move', 'Destination Move', - copy=False, index=True, + move_dest_ids = fields.Many2many( + 'stock.move', 'stock_move_move_rel', 'move_orig_id', 'move_dest_id', 'Destination Moves', + copy=False, help="Optional: next stock move when chaining them") - move_orig_ids = fields.One2many( - 'stock.move', 'move_dest_id', 'Original Move', + move_orig_ids = fields.Many2many( + 'stock.move', 'stock_move_move_rel', 'move_dest_id', 'move_orig_id', 'Original Move', + copy=False, help="Optional: previous stock move when chaining them") picking_id = fields.Many2one('stock.picking', 'Transfer Reference', index=True, states={'done': [('readonly', True)]}) picking_partner_id = fields.Many2one('res.partner', 'Transfer Destination Address', related='picking_id.partner_id') note = fields.Text('Notes') + # TODO: state should be computed according to the move lines state = fields.Selection([ ('draft', 'New'), ('cancel', 'Cancelled'), - ('waiting', 'Waiting Another Move'), ('confirmed', 'Waiting Availability'), - ('assigned', 'Available'), ('done', 'Done')], string='Status', + ('waiting', 'Waiting Another Move'), + ('confirmed', 'Waiting Availability'), + ('partially_available', 'Partially Available'), + ('assigned', 'Available'), + ('done', 'Done')], string='Status', copy=False, default='draft', index=True, readonly=True, help="* New: When the stock move is created and not yet confirmed.\n" "* Waiting Another Move: This state can be seen when a move is waiting for another one, for example in a chained flow.\n" "* Waiting Availability: This state is reached when the procurement resolution is not straight forward. It may need the scheduler to run, a component to be manufactured...\n" "* Available: When products are reserved, it is set to \'Available\'.\n" "* Done: When the shipment is processed, the state is \'Done\'.") - partially_available = fields.Boolean('Partially Available', copy=False, readonly=True, help="Checks if the move has some stock reserved") price_unit = fields.Float( 'Unit Price', help="Technical field used to record the product cost set by the user during a picking confirmation (when costing " "method used is 'average price' or 'real'). Value given in company currency and in product uom.") # as it's a technical field, we intentionally don't provide the digits attribute - split_from = fields.Many2one('stock.move', "Move Split From", copy=False, help="Technical field used to track the origin of a split move, which can be useful in case of debug") backorder_id = fields.Many2one('stock.picking', 'Back Order of', related='picking_id.backorder_id', index=True) origin = fields.Char("Source Document") procure_method = fields.Selection([ @@ -116,9 +121,6 @@ class StockMove(models.Model): "its current stock) to gather products. If we want to chain moves and have this one to wait for the previous," "this second option should be chosen.") scrapped = fields.Boolean('Scrapped', related='location_dest_id.scrap_location', readonly=True, store=True) - linked_move_operation_ids = fields.One2many( - 'stock.move.operation.link', 'move_id', 'Linked Operations', readonly=True, - help='Operations that impact this move for the computation of the remaining quantities') remaining_qty = fields.Float( 'Remaining Quantity', compute='_get_remaining_qty', digits=0, states={'done': [('readonly', True)]}, @@ -132,6 +134,7 @@ class StockMove(models.Model): help='If checked, when this move is cancelled, cancel the linked move too') picking_type_id = fields.Many2one('stock.picking.type', 'Operation Type') inventory_id = fields.Many2one('stock.inventory', 'Inventory') + pack_operation_ids = fields.One2many('stock.pack.operation', 'move_id') origin_returned_move_id = fields.Many2one('stock.move', 'Origin return move', copy=False, help='Move that created the return move') returned_move_ids = fields.One2many('stock.move', 'origin_returned_move_id', 'All returned moves', help='Optional: all returned moves created from this move') reserved_availability = fields.Float( @@ -147,12 +150,42 @@ class StockMove(models.Model): restrict_partner_id = fields.Many2one('res.partner', 'Owner ', help="Technical field used to depict a restriction on the ownership of quants to consider when marking this move as 'done'") route_ids = fields.Many2many('stock.location.route', 'stock_location_route_move', 'move_id', 'route_id', 'Destination route', help="Preferred route to be followed by the procurement order") warehouse_id = fields.Many2one('stock.warehouse', 'Warehouse', help="Technical field depicting the warehouse to consider for the route selection on the next procurement (if any).") + has_tracking = fields.Selection(related='product_id.tracking', string='Product with Tracking') + quantity_done = fields.Float('Quantity', compute='_qty_done_compute', digits=dp.get_precision('Product Unit of Measure')) @api.one @api.depends('product_id', 'product_uom', 'product_uom_qty') def _compute_product_qty(self): - if self.product_uom: - self.product_qty = self.product_uom._compute_quantity(self.product_uom_qty, self.product_id.uom_id) + self.product_qty = self.product_uom._compute_quantity(self.product_uom_qty, self.product_id.uom_id) + + def _get_move_lines(self): + """ 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.pack_operation_ids + + @api.multi + @api.depends('pack_operation_ids.qty_done', 'pack_operation_ids.product_uom_id') + def _quantity_done_compute(self): + for move in self: + 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) + + @api.multi + def _quantity_done_set(self): + quantity_done = self[0].quantity_done # any call to create will invalidate `move.quantity_done` + for move in self: + move_lines = move._get_move_lines() + if not move_lines: + if quantity_done: + # do not impact reservation here + move_line = self.env['stock.pack.operation'].create(dict(move._prepare_move_line_vals(), qty_done=quantity_done)) + move.write({'pack_operation_ids': [(4, move_line.id)]}) + elif len(move_lines) == 1: + move_lines[0].qty_done = quantity_done + else: + raise UserError("Cannot set the done quantity from this stock move, work directly with the move lines.") def _set_product_qty(self): """ The meaning of product_qty field changed lately and is now a functional field computing the quantity @@ -162,24 +195,32 @@ class StockMove(models.Model): raise UserError(_('The requested operation cannot be processed because of a programming error setting the `product_qty` field instead of the `product_uom_qty`.')) @api.one - @api.depends('linked_move_operation_ids.qty') def _get_remaining_qty(self): - self.remaining_qty = float_round(self.product_qty - sum(self.mapped('linked_move_operation_ids').mapped('qty')), precision_rounding=self.product_id.uom_id.rounding) + # TODO: sle jco + pass @api.one - @api.depends('reserved_quant_ids.qty') + @api.depends('pack_operation_ids.product_qty') def _compute_reserved_availability(self): - self.reserved_availability = sum(self.mapped('reserved_quant_ids').mapped('qty')) + """ Fill the `availability` field on a stock move, which is the actual reserved quantity + and is represented by the aggregated `product_qty` on the linked move lines. If the move + is force assigned, the value will be 0. + """ + self.reserved_availability = self.product_id.uom_id._compute_quantity(sum(self.pack_operation_ids.mapped('product_qty')), self.product_uom, rounding_method='HALF-UP') @api.one @api.depends('state', 'product_id', 'product_qty', 'location_id') def _compute_product_availability(self): + """ Fill the `availability` field on a stock move, which is the quantity to potentially + reserve. When the move is done, `availability` is set to the quantity the move did actually + move. + """ if self.state == 'done': self.availability = self.product_qty else: - quants = self.env['stock.quant'].search([('location_id', 'child_of', self.location_id.id), ('product_id', '=', self.product_id.id), ('reservation_id', '=', False)]) - self.availability = min(self.product_qty, sum(quants.mapped('qty'))) + total_availability = self.env['stock.quant']._get_available_quantity(self.product_id, self.location_id) + self.availability = min(self.product_qty, total_availability) @api.multi def _compute_string_qty_information(self): @@ -242,6 +283,10 @@ class StockMove(models.Model): @api.multi def write(self, vals): + # FIXME: pim fix your crap + if vals.get('product_uom_qty') and self.env.context.get('do_not_unreserve') is None: + self.filtered(lambda m: m.state not in ['draft', 'done', 'cancel']).do_unreserve() + # TDE CLEANME: it is a gros bordel + tracking Picking = self.env['stock.picking'] # Check that we do not modify a stock.move which is done @@ -262,7 +307,7 @@ class StockMove(models.Model): if not self._context.get('do_not_propagate', False) and (propagated_date_field or propagated_changes_dict): #any propagation is (maybe) needed for move in self: - if move.move_dest_id and move.propagate: + if move.move_dest_ids and move.propagate: if 'date_expected' in propagated_changes_dict: propagated_changes_dict.pop('date_expected') if propagated_date_field: @@ -290,6 +335,43 @@ class StockMove(models.Model): pickings.message_track(pickings.fields_get(['state']), initial_values) return res + @api.multi + def action_show_details(self): + """ Returns an action that will open a form view (in a popup) allowing to work on all the + move lines of a particular move. This form view is used when "show operations" is not + checked on the picking type. + """ + self.ensure_one() + + # If "show suggestions" is not checked on the picking type, we have to filter out the + # reserved move lines. We do this by displaying `pack_operation_nosuggest_ids`. We use + # different views to display one field or another so that the webclient doesn't have to + # fetch both. + if self.picking_id.picking_type_id.show_reserved: + view = self.env.ref('stock.view_stock_move_operations') + else: + view = self.env.ref('stock.view_stock_move_nosuggest_operations') + + return { + 'name': _('Detailed Operations'), + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'stock.move', + 'views': [(view.id, 'form')], + 'view_id': view.id, + 'target': 'new', + 'res_id': self.id, + 'context': dict( + self.env.context, + show_lots_m2o=self.has_tracking != 'none' and (self.picking_type_id.use_existing_lots or self.state == 'done'), # able to create lots, whatever the value of ` use_create_lots`. + show_lots_text=self.has_tracking != 'none' and self.picking_type_id.use_create_lots and not self.picking_type_id.use_existing_lots and self.state != 'done', + show_source_location=self.location_id.child_ids, + show_destination_location=self.location_dest_id.child_ids, + show_package=not self.location_id.usage == 'supplier', + ), + } + # Misc tools # ------------------------------------------------------------ @@ -297,18 +379,6 @@ class StockMove(models.Model): """ Returns the unit price to store on the quant """ return self.price_unit or self.product_id.standard_price - @api.returns('self') - @api.multi # TDE: DECORATOR to remove - def get_ancestors(self): - '''Find the first level ancestors of given move ''' - ancestors = self.env['stock.move'] - move = self - while move: - ancestors |= move.move_orig_ids - move = not move.move_orig_ids and move.split_from or False - return ancestors - find_move_ancestors = get_ancestors - def _filter_closed_moves(self): """ Helper methods when having to avoid working on moves that are already done or canceled. In a lot of cases you may handle a batch @@ -323,14 +393,24 @@ class StockMove(models.Model): @api.multi def do_unreserve(self): - pass + if any(move.state in ('done', 'cancel') for move in self): + raise UserError(_('Cannot unreserve a done move')) + for move in self: + move.move_line_ids.unlink() + if(move.procure_method == 'make_to_order' and not move.move_orig_ids): + move.state = 'waiting' + elif(move.move_orig_ids and not all(orig.state in ('done', 'cancel') for orig in move.move_orig_ids)): + move.state = 'waiting' + else: + move.state = 'confirmed' + return True def _push_apply(self): # TDE CLEANME: I am quite sure I already saw this code somewhere ... in routing ?? Push = self.env['stock.location.path'] for move in self: # if the move is already chained, there is no need to check push rules - if move.move_dest_id: + if move.move_dest_ids: continue # if the move is a returned move, we don't want to check push rules, as returning a returned move is the only decent way # to receive goods without triggering the push rules again (which would duplicate chained operations) @@ -348,7 +428,6 @@ class StockMove(models.Model): # Make sure it is not returning the return if rules and (not move.origin_returned_move_id or move.origin_returned_move_id.location_dest_id.id != rules.location_dest_id.id): rules._apply(move) - return True @api.onchange('product_id', 'product_qty') def onchange_quantity(self): @@ -372,6 +451,19 @@ class StockMove(models.Model): if self.date_expected: self.date = self.date_expected + @api.onchange('product_uom') + def onchange_product_uom(self): + if self.product_uom.factor > self.product_id.uom_id.factor: + return { + 'warning': { + 'title': "Unsafe unit of measure", + 'message': _("You are using a unit of measure smaller than the one you are using in " + "order to stock your product. This can lead to rounding problem on reserved quantity! " + "You should use the smaller unit of measure possible in order to valuate your stock or " + "change its rounding precision to a smaller value (example: 0.00001)."), + } + } + # TDE DECORATOR: remove that api.multi when action_confirm is migrated @api.multi def assign_picking(self): @@ -431,20 +523,11 @@ class StockMove(models.Model): # if the move is preceeded, then it's waiting (if preceeding move is done, then action_assign has been called already and its state is already available) if move.move_orig_ids: move_waiting |= move - # if the move is split and some of the ancestor was preceeded, then it's waiting as well else: - inner_move = move.split_from - while inner_move: - if inner_move.move_orig_ids: - move_waiting |= move - break - inner_move = inner_move.split_from + if move.procure_method == 'make_to_order': + move_create_proc |= move else: - if move.procure_method == 'make_to_order': - move_create_proc |= move - else: - move_to_confirm |= move - + move_to_confirm |= move if not move.picking_id and move.picking_type_id: key = (move.group_id.id, move.location_id.id, move.location_dest_id.id) if key not in to_assign: @@ -477,6 +560,7 @@ class StockMove(models.Model): attribute_price = set_default_price_unit_from_product def _prepare_procurement_from_move(self): + self.ensure_one() origin = (self.group_id and (self.group_id.name + ":") or "") + (self.rule_id and self.rule_id.name or self.origin or self.picking_id.name or "/") group_id = self.group_id and self.group_id.id or False if self.rule_id: @@ -502,18 +586,11 @@ class StockMove(models.Model): @api.multi def force_assign(self): - # TDE CLEANME: removed return value - self.write({'state': 'assigned'}) - self.check_recompute_pack_op() - - # TDE DECORATOR: internal - @api.multi - def check_recompute_pack_op(self): - pickings = self.mapped('picking_id').filtered(lambda picking: picking.state not in ('waiting', 'confirmed')) # In case of 'all at once' delivery method it should not prepare pack operations - # Check if someone was treating the picking already - pickings_partial = pickings.filtered(lambda picking: not any(operation.qty_done for operation in picking.pack_operation_ids)) - pickings_partial.do_prepare_partial() - (pickings - pickings_partial).write({'recompute_pack_op': True}) + """ Allow to work on stock move lines even if the reservationis not possible. We just mark + the move as assigned, so the view does not block the user. + """ + for move in self.filtered(lambda m: m.state in ['confirmed', 'waiting', 'partially_available', 'assigned']): + move.write({'state': 'assigned'}) @api.multi def check_tracking(self, pack_operation): @@ -526,151 +603,263 @@ class StockMove(models.Model): not (move.restrict_lot_id or (pack_operation and (pack_operation.product_id and pack_operation.pack_lot_ids)) or (pack_operation and not pack_operation.product_id)): raise UserError(_('You need to provide a Lot/Serial Number for product %s') % move.product_id.name) + def _prepare_move_line_vals(self, quantity=None, reserved_quant=None): + self.ensure_one() + # apply putaway + location_dest_id = self.location_dest_id.get_putaway_strategy(self.product_id).id or self.location_dest_id.id + vals = { + 'move_id': self.id, + 'product_id': self.product_id.id, + 'product_uom_id': self.product_uom.id, + 'location_id': self.location_id.id, + 'location_dest_id': location_dest_id, + 'picking_id': self.picking_id.id, + } + 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) + if reserved_quant: + vals = dict( + vals, + location_id=reserved_quant.location_id.id, + lot_id=reserved_quant.lot_id.id or False, + package_id=reserved_quant.package_id.id or False, + owner_id =reserved_quant.owner_id.id or False, + ) + return vals + + def _increase_reserved_quantity(self, need, available_quantity, location_id, lot_id=None, package_id=None, owner_id=None, strict=True): + """ Create or update move lines. + """ + self.ensure_one() + + if not lot_id: + lot_id = self.env['stock.production.lot'] + if not package_id: + package_id = self.env['stock.quant.package'] + if not owner_id: + owner_id = self.env['res.partner'] + + taken_quantity = min(available_quantity, need) + + # Find a candidate move line to update or create a new one. + quants = self.env['stock.quant']._increase_reserved_quantity( + self.product_id, location_id, taken_quantity, lot_id=lot_id, + package_id=package_id, owner_id=owner_id, strict=strict + ) + for reserved_quant, quantity in quants: + to_update = self.pack_operation_ids.filtered(lambda m: 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(taken_quantity, self.product_uom, rounding_method='HALF-UP') + else: + if self.product_id.tracking == 'serial': + for i in range(0, int(quantity)): + self.env['stock.pack.operation'].create(self._prepare_move_line_vals(quantity=1, reserved_quant=reserved_quant)) + else: + self.env['stock.pack.operation'].create(self._prepare_move_line_vals(quantity=quantity, reserved_quant=reserved_quant)) + return taken_quantity + @api.multi def action_assign(self): - return True + """ Reserve stock moves by creating their stock move lines. A stock move is + considered reserved once the sum of `product_qty` for all its move lines is + equal to its `product_qty`. If it is less, the stock move is considered + partially available. + """ + for move in self.filtered(lambda m: m.state in ['confirmed', 'waiting', 'partially_available']): + if move.location_id.usage in ('supplier', 'inventory', 'production', 'customer')\ + or move.product_id.type == 'consu': + # create the move line(s) but do not impact quants + if move.product_id.tracking == 'serial': + for i in range(0, int(move.product_qty)): + move_line_id = self.env['stock.pack.operation'].create(move._prepare_move_line_vals(quantity=1)) + move.write({'pack_operation_ids': [(4, move_line_id.id, 0)]}) + else: + move_line_id = self.env['stock.pack.operation'].create(move._prepare_move_line_vals(quantity=move.product_qty)) + move.write({'pack_operation_ids': [(4, move_line_id.id, 0)]}) + move.write({'state': 'assigned'}) + else: + if not move.move_orig_ids: + if move.procure_method == 'make_to_order': + 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._increase_reserved_quantity(need, available_quantity, move.location_id, strict=False) + if need == taken_quantity: + move.state = 'assigned' + else: + move.state = 'partially_available' + else: + # Check what our parents brought and what our siblings took in order to + # determine what we can distribute. + move_lines_in = move.move_orig_ids.filtered(lambda m: m.state == 'done').mapped('pack_operation_ids') + keys_in = ['location_dest_id', 'lot_id', 'result_package_id', 'owner_id'] + grouped_move_lines_in = {} + for k, g in groupby(sorted(move_lines_in, key=itemgetter(*keys_in)), key=itemgetter(*keys_in)): + grouped_move_lines_in[k] = sum(self.env['stock.pack.operation'].concat(*list(g)).mapped('qty_done')) + + move_lines_out_done = (move.move_orig_ids.mapped('move_dest_ids') - move)\ + .filtered(lambda m: m.state in ['done'])\ + .mapped('pack_operation_ids') + move_lines_out_reserved = (move.move_orig_ids.mapped('move_dest_ids') - move)\ + .filtered(lambda m: m.state in ['partially_available', 'assigned'])\ + .mapped('pack_operation_ids') + keys_out = ['location_id', 'lot_id', 'package_id', 'owner_id'] + grouped_move_lines_out = {} + for k, g in groupby(sorted(move_lines_out_done, key=itemgetter(*keys_out)), key=itemgetter(*keys_out)): + grouped_move_lines_out[k] = sum(self.env['stock.pack.operation'].concat(*list(g)).mapped('qty_done')) + for k, g in groupby(sorted(move_lines_out_reserved, key=itemgetter(*keys_out)), key=itemgetter(*keys_out)): + grouped_move_lines_out[k] = sum(self.env['stock.pack.operation'].concat(*list(g)).mapped('product_qty')) + available_move_lines = {key: grouped_move_lines_in[key] - grouped_move_lines_out.get(key, 0) for key in grouped_move_lines_in.keys()} + # pop key if the quantity available amount to 0 + available_move_lines = dict((k, v) for k, v in available_move_lines.iteritems() if v) + + if not available_move_lines: + continue + for move_line in move.move_line_ids.filtered(lambda m: m.product_qty): + if available_move_lines.get((move_line.location_id, move_line.lot_id, move_line.result_package_id, move_line.owner_id)): + 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.pack_operation_ids.mapped('product_qty')) + taken_quantity = move._increase_reserved_quantity(need, quantity, location_id, lot_id, package_id, owner_id) + if need - taken_quantity == 0.0: + move.state = 'assigned' + break + if move.state != 'partially_available': + move.state = 'partially_available' @api.multi def action_cancel(self): + if any(move.state == 'done' for move in self): + raise UserError(_('You cannot cancel a stock move that has been set to \'Done\'.')) + for move in self: + move.do_unreserve() + siblings_states = (move.move_dest_ids.mapped('move_orig_ids') - move).mapped('state') + if move.propagate: + # only cancel the next move if all my siblings are also cancelled + if all(state == 'cancel' for state in siblings_states): + move.move_dest_ids.action_cancel() + else: + if all(state in ('done', 'cancel') for state in siblings_states): + move.move_dest_ids.write({'procure_method': 'make_to_stock'}) + move.move_dest_ids.write({'move_orig_ids': [(3, move.id, 0)]}) + self.write({'state': 'cancel', 'move_orig_ids': [(5, 0, 0)]}) + self.mapped('procurement_id').check() return True @api.multi - def action_done(self): - """ Process completely the moves given and if all moves are done, it will finish the picking. """ - self.filtered(lambda move: move.state == 'draft').action_confirm() + def _create_extra_move(self): + """ If the quantity done on a move exceeds its quantity todo, this method will create an + extra move attached to a (potentially split) move line. If the previous condition is not + met, it'll return an empty recordset. + + The rationale for the creation of an extra move is the application of a potential push + rule that will handle the extra quantities. + """ + self.ensure_one() + extra_move = self.env['stock.move'] + rounding = self.product_uom.rounding + # moves created after the picking is assigned do not have `product_uom_qty`, but we shouldn't create extra moves for them + if self.product_uom_qty and float_compare(self.quantity_done, self.product_uom_qty, precision_rounding=rounding) > 0: + # 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') + extra_move_vals = { + 'product_uom_qty': extra_move_quantity, + 'picking_id': self.picking_id.id} + extra_move = self.copy(default=extra_move_vals).action_confirm() - Uom = self.env['product.uom'] - Quant = self.env['stock.quant'] - - pickings = self.env['stock.picking'] - procurements = self.env['procurement.order'] - operations = self.env['stock.pack.operation'] - - remaining_move_qty = {} - - for move in self: - if move.picking_id: - pickings |= move.picking_id - remaining_move_qty[move.id] = move.product_qty - for link in move.linked_move_operation_ids: - operations |= link.operation_id - pickings |= link.operation_id.picking_id - - # Sort operations according to entire packages first, then package + lot, package only, lot only - operations = operations.sorted(key=lambda x: ((x.package_id and not x.product_id) and -4 or 0) + (x.package_id and -2 or 0) + (x.pack_lot_ids and -1 or 0)) - - for operation in operations: - - # product given: result put immediately in the result package (if False: without package) - # but if pack moved entirely, quants should not be written anything for the destination package - quant_dest_package_id = operation.product_id and operation.result_package_id.id or False - entire_pack = not operation.product_id and True or False - - # compute quantities for each lot + check quantities match - lot_quantities = dict((pack_lot.lot_id.id, operation.product_uom_id._compute_quantity(pack_lot.qty, operation.product_id.uom_id) - ) for pack_lot in operation.pack_lot_ids) - - qty = operation.product_qty - if operation.product_uom_id and operation.product_uom_id != operation.product_id.uom_id: - qty = operation.product_uom_id._compute_quantity(qty, operation.product_id.uom_id) - if operation.pack_lot_ids and float_compare(sum(pycompat.values(lot_quantities)), qty, precision_rounding=operation.product_id.uom_id.rounding) != 0.0: - raise UserError(_('You have a difference between the quantity on the operation and the quantities specified for the lots. ')) - - quants_taken = [] - false_quants = [] - lot_move_qty = {} - - prout_move_qty = {} - for link in operation.linked_move_operation_ids: - prout_move_qty[link.move_id] = prout_move_qty.get(link.move_id, 0.0) + link.qty - - # Process every move only once for every pack operation - for move in list(prout_move_qty): - # TDE FIXME: do in batch ? - move.check_tracking(operation) - - # TDE FIXME: I bet the message error is wrong - if not remaining_move_qty.get(move.id): - raise UserError(_("The roundings of your unit of measure %s on the move vs. %s on the product don't allow to do these operations or you are not transferring the picking at once. ") % (move.product_uom.name, move.product_id.uom_id.name)) - - if not operation.pack_lot_ids: - preferred_domain_list = [[('reservation_id', '=', move.id)], [('reservation_id', '=', False)], ['&', ('reservation_id', '!=', move.id), ('reservation_id', '!=', False)]] - quants = Quant.quants_get_preferred_domain( - prout_move_qty[move], move, ops=operation, domain=[('qty', '>', 0)], - preferred_domain_list=preferred_domain_list) - Quant.quants_move(quants, move, operation.location_dest_id, location_from=operation.location_id, - lot_id=False, owner_id=operation.owner_id.id, src_package_id=operation.package_id.id, - dest_package_id=quant_dest_package_id, entire_pack=entire_pack) + # link it to some move lines + for move_line in self.pack_operation_ids.filtered(lambda ml: ml.qty_done): + if float_compare(move_line.qty_done, extra_move_quantity, precision_rounding=rounding) <= 0: + # move this move line to our extra move + move_line.move_id = extra_move.id + extra_move_quantity -= move_line.qty_done else: - # Check what you can do with reserved quants already - qty_on_link = prout_move_qty[move] - rounding = operation.product_id.uom_id.rounding - for reserved_quant in move.reserved_quant_ids: - if (reserved_quant.owner_id.id != operation.owner_id.id) or (reserved_quant.location_id.id != operation.location_id.id) or \ - (reserved_quant.package_id.id != operation.package_id.id): - continue - if not reserved_quant.lot_id: - false_quants += [reserved_quant] - elif float_compare(lot_quantities.get(reserved_quant.lot_id.id, 0), 0, precision_rounding=rounding) > 0: - if float_compare(lot_quantities[reserved_quant.lot_id.id], reserved_quant.qty, precision_rounding=rounding) >= 0: - lot_quantities[reserved_quant.lot_id.id] -= reserved_quant.qty - quants_taken += [(reserved_quant, reserved_quant.qty)] - qty_on_link -= reserved_quant.qty - else: - quants_taken += [(reserved_quant, lot_quantities[reserved_quant.lot_id.id])] - lot_quantities[reserved_quant.lot_id.id] = 0 - qty_on_link -= lot_quantities[reserved_quant.lot_id.id] - lot_move_qty[move.id] = qty_on_link + # split this move line and assign the new part to our extra move + quantity_split = float_round( + move_line.qty_done - extra_move_quantity, + precision_rounding=self.product_uom.rounding, + rounding_method='UP') + move_line.qty_done = quantity_split + move_line.copy(default={'move_id': extra_move.id, 'qty_done': extra_move_quantity, 'product_uom_qty': 0}) + extra_move_quantity -= extra_move_quantity + if extra_move_quantity == 0.0: + break + return extra_move - remaining_move_qty[move.id] -= prout_move_qty[move] + @api.multi + def action_done(self): + self.filtered(lambda move: move.state == 'draft').action_confirm() # MRP allows scrapping draft moves - # Handle lots separately - if operation.pack_lot_ids: - # TDE FIXME: fix call to move_quants_by_lot to ease understanding - self._move_quants_by_lot(operation, lot_quantities, quants_taken, false_quants, lot_move_qty, quant_dest_package_id) + moves = self.filtered(lambda x: x.state not in ('done', 'cancel')) + moves_todo = self.env['stock.move'] + # Create extra moves where necessary + for move in moves: + # Here, the `quantity_done` was already rounded to the product UOM by the `do_produce` wizard. However, + # it is possible that the user changed the value before posting the inventory by a value that should be + # rounded according to the move's UOM. In this specific case, we chose to round up the value, because it + # is what is expected by the user (if i consumed/produced a little more, the whole UOM unit should be + # consumed/produced and the moves are split correctly). + # FIXME: move rounding to move line + # rounding = move.product_uom.rounding + # move.quantity_done = float_round(move.quantity_done, precision_rounding=rounding, rounding_method ='UP') + if move.quantity_done <= 0: + continue + moves_todo |= move + moves_todo |= move._create_extra_move() + # 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: + # 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) + new_move = move.split(qty_split) + for move_line in move.pack_operation_ids: + if move_line.product_qty: + # FIXME: there will be an issue if the move was partially available + # By decreasing `product_qty`, we free the reservation. + # FIXME: if qty_done > product_qty, this could raise if nothing is in stock + try: + move_line.write({'product_uom_qty': move_line.qty_done}) + except UserError: + pass - # Handle pack in pack - if not operation.product_id and operation.package_id and operation.result_package_id.id != operation.package_id.parent_id.id: - operation.package_id.sudo().write({'parent_id': operation.result_package_id.id}) - - # Check for remaining qtys and unreserve/check move_dest_id in - move_dest_ids = set() - for move in self: - if float_compare(remaining_move_qty[move.id], 0, precision_rounding=move.product_id.uom_id.rounding) > 0: # In case no pack operations in picking - move.check_tracking(False) # TDE: do in batch ? redone ? check this - - preferred_domain_list = [[('reservation_id', '=', move.id)], [('reservation_id', '=', False)], ['&', ('reservation_id', '!=', move.id), ('reservation_id', '!=', False)]] - quants = Quant.quants_get_preferred_domain( - remaining_move_qty[move.id], move, domain=[('qty', '>', 0)], - preferred_domain_list=preferred_domain_list) - Quant.quants_move( - quants, move, move.location_dest_id, - lot_id=move.restrict_lot_id.id, owner_id=move.restrict_partner_id.id) - - # If the move has a destination, add it to the list to reserve - if move.move_dest_id and move.move_dest_id.state in ('waiting', 'confirmed'): - move_dest_ids.add(move.move_dest_id.id) - - if move.procurement_id: - procurements |= move.procurement_id - - # unreserve the quants and make them available for other operations/moves - move.quants_unreserve() - - # Check the packages have been placed in the correct locations - self.mapped('quant_ids').filtered(lambda quant: quant.package_id and quant.qty > 0).mapped('package_id')._check_location_constraint() - - # set the move as done - self.write({'state': 'done', 'date': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)}) - procurements.check() - # assign destination moves - if move_dest_ids: - # TDE FIXME: record setise me - self.browse(list(move_dest_ids)).action_assign() - - pickings.filtered(lambda picking: picking.state == 'done' and not picking.date_done).write({'date_done': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)}) - - return True + # If you were already putting stock.move.lots on the next one in the work order, transfer those to the new move + move.pack_operation_ids.filtered(lambda x: x.qty_done == 0.0).write({'move_id': new_move}) + self.browse(new_move).quantity_done = 0.0 + for packop in move.pack_operation_ids: + if float_compare(packop.qty_done, 0, precision_rounding=rounding) > 0: + if move.has_tracking != 'none' and (move.picking_type_id.use_create_lots or move.picking_type_id.use_existing_lots): + if packop.lot_name and not packop.lot_id: + lot = self.env['stock.production.lot'].create( + {'name': packop.lot_name, 'product_id': packop.product_id.id}) + packop.write({'lot_id': lot.id}) + if not packop.lot_id: + raise UserError(_('You need to supply a lot/serial number.')) + # execute `action_done` even if nothing was done, to free the reservation + packop.action_done() + picking = self and self[0].picking_id or False + moves_todo.write({'state': 'done', 'date': fields.Datetime.now()}) + moves_todo.mapped('move_dest_ids').action_assign() + if picking: + moves_to_backorder = picking.move_lines.filtered(lambda x: x.state not in ('done', 'cancel')) + if moves_to_backorder: + backorder_picking = picking.copy({ + 'name': '/', + 'move_lines': [], + 'pack_operation_ids': [], + 'backorder_id': picking.id + }) + picking.message_post('Backorder Created') #message needs to be improved + moves_to_backorder.write({'picking_id': backorder_picking.id}) + moves_to_backorder.action_assign() + return moves_todo @api.multi def unlink(self): @@ -702,9 +891,9 @@ class StockMove(models.Model): 'product_uom_qty': uom_qty, 'procure_method': 'make_to_stock', 'restrict_lot_id': restrict_lot_id, - 'split_from': self.id, 'procurement_id': self.procurement_id.id, - 'move_dest_id': self.move_dest_id.id, + '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, } if restrict_partner_id: @@ -717,17 +906,31 @@ class StockMove(models.Model): # ctx = context.copy() # TDE CLEANME: used only in write in this file, to clean # ctx['do_not_propagate'] = True - self.with_context(do_not_propagate=True).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}) + # FIXME: pim fix your crap + self.with_context(do_not_propagate=True, do_not_unreserve=True).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) new_move.action_confirm() # TDE FIXME: due to action confirm change return new_move.id + def _recompute_state(self): + for move in self: + if move.reserved_availability == move.product_uom_qty: + move.state = 'assigned' + elif move.reserved_availability and move.reserved_availability <= move.product_uom_qty: + move.state = 'partially_available' + else: + if move.move_orig_ids: + move.state = 'waiting' + else: + move.state = 'confirmed' + @api.multi def action_show_picking(self): view = self.env.ref('stock.view_picking_form') diff --git a/addons/stock/models/stock_pack_operation.py b/addons/stock/models/stock_pack_operation.py index 4de97bc9cc3..e5fd2a866e0 100644 --- a/addons/stock/models/stock_pack_operation.py +++ b/addons/stock/models/stock_pack_operation.py @@ -4,9 +4,9 @@ from odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp -from odoo.exceptions import UserError, ValidationError -from odoo.tools.float_utils import float_round, float_compare +from odoo.exceptions import UserError from odoo.tools.pycompat import izip +from odoo.tools.float_utils import float_round, float_compare, float_is_zero class PackOperation(models.Model): @@ -14,104 +14,40 @@ class PackOperation(models.Model): _description = "Packing Operation" _order = "result_package_id desc, id" - # TDE FIXME: strange, probably to remove - def _get_default_from_loc(self): - default_loc = self.env.context.get('default_location_id') - if default_loc: - return self.env['stock.location'].browse(default_loc).name - - # TDE FIXME: strange, probably to remove - def _get_default_to_loc(self): - default_loc = self.env.context.get('default_location_dest_id') - if default_loc: - return self.env['stock.location'].browse(default_loc).name picking_id = fields.Many2one( 'stock.picking', 'Stock Picking', - required=True, help='The stock operation where the packing has been made') - product_id = fields.Many2one('product.product', 'Product', ondelete="cascade") - product_uom_id = fields.Many2one('product.uom', 'Unit of Measure') - product_qty = fields.Float('To Do', default=0.0, digits=dp.get_precision('Product Unit of Measure'), required=True) + move_id = fields.Many2one( + 'stock.move', 'Stock Move', + help="Change to a better name") + product_id = fields.Many2one('product.product', 'Product', ondelete="cascade") #might be a related with the move also --> no, because you can put them next to each other + product_uom_id = fields.Many2one('product.uom', 'Unit of Measure', required=True) + product_qty = fields.Float( + 'Real Reserved Quantity', digits=0, + compute='_compute_product_qty', inverse='_set_product_qty', store=True) + product_uom_qty = fields.Float('Reserved', default=0.0, digits=dp.get_precision('Product Unit of Measure'), required=True) ordered_qty = fields.Float('Ordered Quantity', digits=dp.get_precision('Product Unit of Measure')) - qty_done = fields.Float('Done', default=0.0, digits=dp.get_precision('Product Unit of Measure')) - is_done = fields.Boolean(compute='_compute_is_done', string='Done', readonly=False, oldname='processed_boolean') + qty_done = fields.Float('Done', default=0.0, digits=dp.get_precision('Product Unit of Measure'), copy=False) package_id = fields.Many2one('stock.quant.package', 'Source Package') - pack_lot_ids = fields.One2many('stock.pack.operation.lot', 'operation_id', 'Lots/Serial Numbers Used') result_package_id = fields.Many2one( 'stock.quant.package', 'Destination Package', ondelete='cascade', required=False, help="If set, the operations are packed into this package") - date = fields.Datetime('Date', default=fields.Date.context_today, required=True) + lot_id = fields.Many2one('stock.production.lot', 'Lot') + date = fields.Datetime('Date', default=fields.Datetime.now(), required=True) owner_id = fields.Many2one('res.partner', 'Owner', help="Owner of the quants") - linked_move_operation_ids = fields.One2many( - 'stock.move.operation.link', 'operation_id', string='Linked Moves', - readonly=True, - help='Moves impacted by this operation for the computation of the remaining quantities') - remaining_qty = fields.Float( - compute='_get_remaining_qty', string="Remaining Qty", digits=0, - help="Remaining quantity in default UoM according to moves matched with this operation.") - location_id = fields.Many2one('stock.location', 'Source Location', required=True) - location_dest_id = fields.Many2one('stock.location', 'Destination Location', required=True) - picking_source_location_id = fields.Many2one('stock.location', related='picking_id.location_id') - picking_destination_location_id = fields.Many2one('stock.location', related='picking_id.location_dest_id') - # TDE FIXME: unnecessary fields IMO, to remove - from_loc = fields.Char(compute='_compute_location_description', default=_get_default_from_loc, string='From') - to_loc = fields.Char(compute='_compute_location_description', default=_get_default_to_loc, string='To') - fresh_record = fields.Boolean('Newly created pack operation', default=True) + location_id = fields.Many2one('stock.location', 'From', required=True) + location_dest_id = fields.Many2one('stock.location', 'To', required=True) + from_loc = fields.Char(compute='_compute_location_description') + to_loc = fields.Char(compute='_compute_location_description') lots_visible = fields.Boolean(compute='_compute_lots_visible') - state = fields.Selection(selection=[ - ('draft', 'Draft'), - ('cancel', 'Cancelled'), - ('waiting', 'Waiting Another Operation'), - ('confirmed', 'Waiting Availability'), - ('partially_available', 'Partially Available'), - ('assigned', 'Available'), - ('done', 'Done')], related='picking_id.state') + state = fields.Selection(related='move_id.state') + consume_line_ids = fields.Many2many('stock.move.line', 'stock_move_line_consume_rel', 'consume_line_id', 'produce_line_id', help="Technical link to see who consumed what. ") + produce_line_ids = fields.Many2many('stock.move.line', 'stock_move_line_consume_rel', 'produce_line_id', 'consume_line_id', help="Technical link to see which line was produced with this. ") + @api.one - def _compute_is_done(self): - self.is_done = self.qty_done > 0.0 - - @api.onchange('is_done') - def on_change_is_done(self): - if not self.product_id: - if self.is_done and self.qty_done == 0: - self.qty_done = 1.0 - if not self.is_done and self.qty_done != 0: - self.qty_done = 0.0 - - def _get_remaining_prod_quantities(self): - '''Get the remaining quantities per product on an operation with a package. This function returns a dictionary''' - # TDE CLEANME: merge with _get_all_products_quantities in quant to ease code understanding + clean code - # if the operation doesn't concern a package, it's not relevant to call this function - if not self.package_id or self.product_id: - return {self.product_id: self.remaining_qty} - # get the total of products the package contains - res = self.package_id._get_all_products_quantities() - # reduce by the quantities linked to a move - for record in self.linked_move_operation_ids: - if record.move_id.product_id.id not in res: - res[record.move_id.product_id] = 0 - res[record.move_id.product_id] -= record.qty - return res - - @api.one - def _get_remaining_qty(self): - if self.package_id and not self.product_id: - # dont try to compute the remaining quantity for packages because it's not relevant (a package could include different products). - # should use _get_remaining_prod_quantities instead - # TDE FIXME: actually resolve the comment hereabove - self.remaining_qty = 0 - else: - qty = self.product_qty - if self.product_uom_id: - qty = self.product_uom_id._compute_quantity(self.product_qty, self.product_id.uom_id) - for record in self.linked_move_operation_ids: - qty -= record.qty - self.remaining_qty = float_round(qty, precision_rounding=self.product_id.uom_id.rounding) - - @api.multi 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 '') @@ -119,17 +55,24 @@ class PackOperation(models.Model): @api.one def _compute_lots_visible(self): - if self.pack_lot_ids: - self.lots_visible = True - elif self.picking_id.picking_type_id and self.product_id.tracking != 'none': # TDE FIXME: not sure correctly migrated + if self.picking_id.picking_type_id and self.product_id.tracking != 'none': # TDE FIXME: not sure correctly migrated picking = self.picking_id self.lots_visible = picking.picking_type_id.use_existing_lots or picking.picking_type_id.use_create_lots else: self.lots_visible = self.product_id.tracking != 'none' - @api.onchange('pack_lot_ids') - def _onchange_packlots(self): - self.qty_done = sum([x.qty for x in self.pack_lot_ids]) + @api.one + @api.depends('product_id', 'product_uom_id', 'product_uom_qty') + def _compute_product_qty(self): + self.product_qty = self.product_uom_id._compute_quantity(self.product_uom_qty, self.product_id.uom_id, rounding_method='HALF-UP') + + @api.one + def _set_product_qty(self): + """ The meaning of product_qty field changed lately and is now a functional field computing the quantity + in the default product UoM. This code has been added to raise an error if a write is made given a value + for `product_qty`, where the same write should set the `product_uom_qty` field instead, in order to + detect errors. """ + raise UserError(_('The requested operation cannot be processed because of a programming error setting the `product_qty` field instead of the `product_uom_qty`.')) @api.multi @api.onchange('product_id', 'product_uom_id') @@ -137,7 +80,10 @@ class PackOperation(models.Model): if self.product_id: self.lots_visible = self.product_id.tracking != 'none' if not self.product_uom_id or self.product_uom_id.category_id != self.product_id.uom_id.category_id: - self.product_uom_id = self.product_id.uom_id.id + if self.move_id.product_uom: + self.product_uom_id = self.move_id.product_uom.id + else: + self.product_uom_id = self.product_id.uom_id.id res = {'domain': {'product_uom_id': [('category_id', '=', self.product_uom_id.category_id.id)]}} else: res = {'domain': {'product_uom_id': []}} @@ -145,173 +91,230 @@ class PackOperation(models.Model): @api.model def create(self, vals): - vals['ordered_qty'] = vals.get('product_qty') - return super(PackOperation, self).create(vals) + vals['ordered_qty'] = vals.get('product_uom_qty') + ml = super(PackOperation, self).create(vals) + if ml.state == 'done': + Quant = self.env['stock.quant'] + quantity = ml.product_uom_id._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id,rounding_method='HALF-UP') + if ml.location_id.should_impact_quants() and ml.product_id.type == 'product': + available_qty = Quant._decrease_available_quantity(ml.product_id, ml.location_id, quantity, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id) + if available_qty < 0 and ml.lot_id: + # see if we can compensate the negative quants with some untracked quants + untracked_qty = Quant._get_available_quantity(ml.product_id, ml.location_id, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id, strict=True) + if untracked_qty: + taken_from_untracked_qty = min(untracked_qty, abs(quantity)) + Quant._decrease_available_quantity(ml.product_id, ml.location_id, taken_from_untracked_qty, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id) + Quant._increase_available_quantity(ml.product_id, ml.location_id, taken_from_untracked_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id) + if ml.location_dest_id.should_impact_quants() and ml.product_id.type == 'product': + Quant._increase_available_quantity(ml.product_id, ml.location_dest_id, quantity, lot_id=ml.lot_id, package_id=ml.result_package_id, owner_id=ml.owner_id) + next_moves = ml.move_id.move_dest_ids.filtered(lambda move: move.state not in ('done', 'cancel')) + next_moves.do_unreserve() + next_moves.action_assign() + return ml @api.multi - def write(self, values): - # TDE FIXME: weird stuff, protectin pack op ? - values['fresh_record'] = False - return super(PackOperation, self).write(values) + def write(self, vals): + """ Through the interface, we allow users to change the charateristics of a move line. If a + quantity has been reserved for this move line, we impact the reservation directly to free + the old quants and allocate the new ones. + """ + if self.env.context.get('bypass_reservation_update'): + return super(PackOperation, self).write(vals) + + Quant = self.env['stock.quant'] + # We forbid to change the reserved quantity in the interace, but it is needed in the + # case of stock.move's split. + # TODO Move me in the update + if 'product_uom_qty' in vals: + for ml in self.filtered(lambda m: m.state in ('partially_available', 'assigned')): + if ml.location_id.should_impact_quants() and ml.product_id.type == 'product': + qty_to_decrease = ml.product_qty - ml.product_uom_id._compute_quantity(vals['product_uom_qty'], ml.product_id.uom_id, rounding_method='HALF-UP') + try: + Quant._decrease_reserved_quantity(ml.product_id, ml.location_id, qty_to_decrease, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id) + except UserError: + if ml.lot_id: + Quant._decrease_reserved_quantity(ml.product_id, ml.location_id, qty_to_decrease, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id) + else: + raise + + triggers = [ + ('location_id', 'stock.location'), + ('location_dest_id', 'stock.location'), + ('lot_id', 'stock.production.lot'), + ('package_id', 'stock.quant.package'), + ('result_package_id', 'stock.quant.package'), + ('owner_id', 'res.partner') + ] + updates = {} + for key, model in triggers: + if key in vals: + updates[key] = self.env[model].browse(vals[key]) + + if updates: + for ml in self.filtered(lambda ml: ml.state in ['partially_available', 'assigned']): + if ml.location_id.should_impact_quants() and ml.product_id.type == 'product': + try: + Quant._decrease_reserved_quantity(ml.product_id, ml.location_id, ml.product_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id) + except UserError: + if ml.lot_id: + Quant._decrease_reserved_quantity(ml.product_id, ml.location_id, ml.product_qty, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id) + else: + raise + + if updates.get('location_id', ml.location_id).should_impact_quants() and ml.product_id.type == 'product': + new_product_qty = 0 + try: + q = Quant._increase_reserved_quantity(ml.product_id, updates.get('location_id', ml.location_id), ml.product_qty, lot_id=updates.get('lot_id', ml.lot_id), + package_id=updates.get('package_id', ml.package_id), owner_id=updates.get('owner_id', ml.owner_id), strict=True) + new_product_qty = sum([x[1] for x in q]) + except UserError: + if updates.get('lot_id'): + # If we were not able to reserve on tracked quants, we can use untracked ones. + try: + q = Quant._increase_reserved_quantity(ml.product_id, updates.get('location_id', ml.location_id), ml.product_qty, lot_id=False, + package_id=updates.get('package_id', ml.package_id), owner_id=updates.get('owner_id', ml.owner_id), strict=True) + new_product_qty = sum([x[1] for x in q]) + 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') + 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. + next_moves = self.env['stock.move'] + if updates or 'qty_done' in vals: + for ml in self.filtered(lambda ml: ml.move_id.state == 'done'): + # undo the original move line + if ml.location_dest_id.should_impact_quants() and ml.product_id.type == 'product': + Quant._decrease_available_quantity(ml.product_id, ml.location_dest_id, ml.qty_done, lot_id=ml.lot_id, + package_id=ml.package_id, owner_id=ml.owner_id) + if ml.location_id.should_impact_quants() and ml.product_id.type == 'product': + Quant._increase_available_quantity(ml.product_id, ml.location_id, ml.qty_done, lot_id=ml.lot_id, + package_id=ml.package_id, owner_id=ml.owner_id) + + # move what's been actually done + product_id = ml.product_id + location_id = updates.get('location_id', ml.location_id) + location_dest_id = updates.get('location_dest_id', ml.location_dest_id) + qty_done = vals.get('qty_done', ml.qty_done) + lot_id = updates.get('lot_id', ml.lot_id) + package_id = updates.get('package_id', ml.package_id) + result_package_id = updates.get('result_package_id', ml.result_package_id) + owner_id = updates.get('owner_id', ml.owner_id) + quantity = ml.move_id.product_uom._compute_quantity(qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP') + if location_id.should_impact_quants() and ml.product_id.type == 'product': + ml._free_reservation(product_id, location_id, quantity, lot_id=lot_id, package_id=package_id, owner_id=owner_id) + available_qty = Quant._decrease_available_quantity(product_id, location_id, quantity, lot_id=lot_id, package_id=package_id, owner_id=owner_id) + if available_qty < 0 and lot_id: + # see if we can compensate the negative quants with some untracked quants + untracked_qty = Quant._get_available_quantity(product_id, location_id, lot_id=False, package_id=package_id, owner_id=owner_id, strict=True) + if untracked_qty: + taken_from_untracked_qty = min(untracked_qty, abs(available_qty)) + Quant._decrease_available_quantity(product_id, location_id, taken_from_untracked_qty, lot_id=False, package_id=package_id, owner_id=owner_id) + Quant._increase_available_quantity(product_id, location_id, taken_from_untracked_qty, lot_id=lot_id, package_id=package_id, owner_id=owner_id) + ml._free_reservation(ml.product_id, location_id, untracked_qty, lot_id=False, package_id=package_id, owner_id=owner_id) + if location_dest_id.should_impact_quants() and ml.product_id.type == 'product' and qty_done: + Quant._increase_available_quantity(product_id, location_dest_id, quantity, lot_id=lot_id, package_id=result_package_id, owner_id=owner_id) + # Unreserve and reserve following move in order to have the real reserved quantity on move_line. + next_moves |= ml.move_id.move_dest_ids.filtered(lambda move: move.state not in ('done', 'cancel')) + res = super(PackOperation, self).write(vals) + next_moves.do_unreserve() + next_moves.action_assign() + return res @api.multi def unlink(self): - if any([operation.state in ('done', 'cancel') for operation in self]): - raise UserError(_('You can not delete pack operations of a done picking')) + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + for ml in self: + if ml.state in ('done', 'cancel'): + raise UserError(_('You can not delete pack operations of a done picking')) + # Unlinking a pack operation should unreserve. + if ml.location_id.should_impact_quants() and ml.product_id.type == 'product' and not float_is_zero(ml.product_qty, precision_digits=precision): + self.env['stock.quant']._decrease_reserved_quantity(ml.product_id, ml.location_id, ml.product_qty, lot_id=ml.lot_id, + package_id=ml.package_id, owner_id=ml.owner_id) return super(PackOperation, self).unlink() - @api.multi - def split_quantities(self): - for operation in self: - if float_compare(operation.product_qty, operation.qty_done, precision_rounding=operation.product_uom_id.rounding) == 1: - cpy = operation.copy(default={'qty_done': 0.0, 'product_qty': operation.product_qty - operation.qty_done}) - operation.write({'product_qty': operation.qty_done}) - operation._copy_remaining_pack_lot_ids(cpy) - else: - raise UserError(_('The quantity to split should be smaller than the quantity To Do. ')) - return True + def action_done(self): + """ This method will finalize the work with a move line by "moving" quants to the + destination location. + """ + for ml in self: + if ml.product_id.type != 'consu': + Quant = self.env['stock.quant'] + rounding = ml.product_uom_id.rounding - @api.multi - def save(self): - # TDE FIXME: does not seem to be used -> actually, it does - # TDE FIXME: move me somewhere else, because the return indicated a wizard, in pack op, it is quite strange - # HINT: 4. How to manage lots of identical products? - # Create a picking and click on the Mark as TODO button to display the Lot Split icon. A window will pop-up. Click on Add an item and fill in the serial numbers and click on save button - for pack in self: - if pack.product_id.tracking != 'none': - pack.write({'qty_done': sum(pack.pack_lot_ids.mapped('qty'))}) - return {'type': 'ir.actions.act_window_close'} + # if this move line is force assigned, unreserve elsewhere if needed + if float_compare(ml.qty_done, ml.product_qty, precision_rounding=rounding) > 0: + extra_qty = ml.qty_done - ml.product_qty + ml._free_reservation(ml.product_id, ml.location_id, extra_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id) + # unreserve what's been reserved + if ml.location_id.should_impact_quants() and ml.product_id.type == 'product' and ml.product_qty: + try: + Quant._decrease_reserved_quantity(ml.product_id, ml.location_id, ml.product_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id) + except UserError: + Quant._decrease_reserved_quantity(ml.product_id, ml.location_id, ml.product_qty, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id) - @api.multi - def action_split_lots(self): - action_ctx = dict(self.env.context) - # If it's a returned stock move, we do not want to create a lot - returned_move = self.linked_move_operation_ids.mapped('move_id').mapped('origin_returned_move_id') - picking_type = self.picking_id.picking_type_id - action_ctx.update({ - 'serial': self.product_id.tracking == 'serial', - 'only_create': picking_type.use_create_lots and not picking_type.use_existing_lots and not returned_move, - 'create_lots': picking_type.use_create_lots, - 'state_done': self.picking_id.state == 'done', - 'show_reserved': any([lot for lot in self.pack_lot_ids if lot.qty_todo > 0.0])}) - view_id = self.env.ref('stock.view_pack_operation_lot_form').id - return { - 'name': _('Lot/Serial Number Details'), - 'type': 'ir.actions.act_window', - 'view_type': 'form', - 'view_mode': 'form', - 'res_model': 'stock.pack.operation', - 'views': [(view_id, 'form')], - 'view_id': view_id, - 'target': 'new', - 'res_id': self.ids[0], - 'context': action_ctx} + # move what's been actually done + quantity = ml.product_uom_id._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP') + if ml.location_id.should_impact_quants() and ml.product_id.type == 'product': + available_qty = Quant._decrease_available_quantity(ml.product_id, ml.location_id, quantity, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id) + if available_qty < 0 and ml.lot_id: + # see if we can compensate the negative quants with some untracked quants + untracked_qty = Quant._get_available_quantity(ml.product_id, ml.location_id, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id, strict=True) + if untracked_qty: + taken_from_untracked_qty = min(untracked_qty, abs(quantity)) + Quant._decrease_available_quantity(ml.product_id, ml.location_id, taken_from_untracked_qty, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id) + Quant._increase_available_quantity(ml.product_id, ml.location_id, taken_from_untracked_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id) + if ml.location_dest_id.should_impact_quants() and ml.product_id.type == 'product': + Quant._increase_available_quantity(ml.product_id, ml.location_dest_id, quantity, lot_id=ml.lot_id, package_id=ml.result_package_id, owner_id=ml.owner_id) - @api.multi - def show_details(self): - # TDE FIXME: does not seem to be used - view_id = self.env.ref('stock.view_pack_operation_details_form_save').id - return { - 'name': _('Operation Details'), - 'type': 'ir.actions.act_window', - 'view_type': 'form', - 'view_mode': 'form', - 'res_model': 'stock.pack.operation', - 'views': [(view_id, 'form')], - 'view_id': view_id, - 'target': 'new', - 'res_id': self.ids[0], - 'context': self.env.context} + def _free_reservation(self, product_id, location_id, quantity, lot_id=None, package_id=None, owner_id=None): + """ When editing a done move line or validating one with some forced quantities, it is + possible to impact quants that were not reserved. It is therefore necessary to edit or + unlink the move lines that reserved a quantity now unavailable. + """ + self.ensure_one() - @api.multi - def _check_serial_number(self): - for operation in self: - if operation.picking_id and \ - (operation.picking_id.picking_type_id.use_existing_lots or operation.picking_id.picking_type_id.use_create_lots) and \ - operation.product_id and operation.product_id.tracking != 'none' and \ - operation.qty_done > 0.0: - if not operation.pack_lot_ids: - raise UserError(_('You need to provide a Lot/Serial Number for product %s') % operation.product_id.name) - if operation.product_id.tracking == 'serial': - for opslot in operation.pack_lot_ids: - if opslot.qty not in (1.0, 0.0): - raise UserError(_('You should provide a different serial number for each piece')) - check_tracking = _check_serial_number + # Check the available quantity, with the `strict` kw set to `True`. If the available + # quantity is greather than the quantity now unavailable, there is nothing to do. + available_quantity = self.env['stock.quant']._get_available_quantity( + product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=True + ) + if quantity > available_quantity: + # We now have to find the move lines that reserved our now unavailable quantity. We + # take care to exclude ourselves and the move lines were work had already been done. + oudated_move_lines_domain = [ + ('move_id.state', 'not in', ['done', 'cancel']), + ('product_id', '=', product_id.id), + ('lot_id', '=', lot_id.id if lot_id else False), + ('location_id', '=', location_id.id), + ('owner_id', '=', owner_id.id if owner_id else False), + ('package_id', '=', package_id.id if package_id else False), + ('product_qty', '>', 0.0), + ('qty_done', '=', 0.0), + ('id', '!=', self.id), + ] + oudated_candidates = self.env['stock.pack.operation'].search(oudated_move_lines_domain) - @api.multi - def _copy_remaining_pack_lot_ids(self, new_operation): - for op in self: - for lot in op.pack_lot_ids: - new_qty_todo = lot.qty_todo - lot.qty + # As the move's state is not computed over the move lines, we'll have to manually + # recompute the moves which we adapted their lines. + move_to_recompute_state = self.env['stock.move'] - if float_compare(new_qty_todo, 0, precision_rounding=op.product_uom_id.rounding) > 0: - lot.copy({ - 'operation_id': new_operation.id, - 'qty_todo': new_qty_todo, - 'qty': 0, - }) - - -class PackOperationLot(models.Model): - _name = "stock.pack.operation.lot" - _description = "Lot/Serial number for pack ops" - - operation_id = fields.Many2one('stock.pack.operation') - qty = fields.Float('Done', default=1.0) - lot_id = fields.Many2one('stock.production.lot', 'Lot/Serial Number') - lot_name = fields.Char('Lot/Serial Number') - qty_todo = fields.Float('To Do', default=0.0) - plus_visible = fields.Boolean(compute='_compute_plus_visible', default=True) - - _sql_constraints = [ - ('qty', 'CHECK(qty >= 0.0)', 'Quantity must be greater than or equal to 0.0!'), - ('uniq_lot_id', 'unique(operation_id, lot_id)', 'You have already mentioned this lot in another line'), - ('uniq_lot_name', 'unique(operation_id, lot_name)', 'You have already mentioned this lot name in another line')] - - @api.one - def _compute_plus_visible(self): - if self.operation_id.product_id.tracking == 'serial': - self.plus_visible = (self.qty == 0.0) - else: - self.plus_visible = (self.qty_todo == 0.0) or (self.qty < self.qty_todo) - - @api.constrains('lot_id', 'lot_name') - def _check_lot(self): - if any(not lot.lot_name and not lot.lot_id for lot in self): - raise ValidationError(_('Lot/Serial Number required')) - return True - - def action_add_quantity(self, quantity): - for lot in self: - lot.write({'qty': lot.qty + quantity}) - lot.operation_id.write({'qty_done': sum(operation_lot.qty for operation_lot in lot.operation_id.pack_lot_ids)}) - return self.mapped('operation_id').action_split_lots() - - @api.multi - def do_plus(self): - return self.action_add_quantity(1) - - @api.multi - def do_minus(self): - return self.action_add_quantity(-1) - - -class OperationLink(models.Model): - """ Make link between stock.move and stock.pack.operation in order to compute - the remaining quantities on each of those objects. """ - _name = "stock.move.operation.link" - _description = "Pack Operation / Moves Link" - - qty = fields.Float( - 'Quantity', help="Quantity of products to consider when talking about the contribution of this pack operation towards the " - "remaining quantity of the move (and inverse). Given in the product main uom.") - operation_id = fields.Many2one( - 'stock.pack.operation', 'Operation', - ondelete="cascade", required=True) - move_id = fields.Many2one( - 'stock.move', 'Move', - ondelete="cascade", required=True) - reserved_quant_id = fields.Many2one( - 'stock.quant', 'Reserved Quant', - help="Technical field containing the quant that created this link between an operation and a stock move. " - "Used at the stock_move_obj.action_done() time to avoid seeking a matching quant again") + rounding = self.product_uom_id.rounding + for candidate in oudated_candidates: + if float_compare(candidate.product_qty, quantity, precision_rounding=rounding) <= 0: + quantity -= candidate.product_qty + move_to_recompute_state |= candidate.move_id + candidate.unlink() + else: + # split this move line and assign the new part to our extra move + quantity_split = float_round( + candidate.product_qty - quantity, + precision_rounding=self.product_uom_id.rounding, + rounding_method='UP') + candidate.product_uom_qty = self.product_id.uom_id._compute_quantity(quantity_split, self.product_uom_id, rounding_method='HALF-UP') + quantity -= quantity_split + move_to_recompute_state |= candidate.move_id + if quantity == 0.0: + break + move_to_recompute_state._recompute_state() diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index c02b962ef81..de261ea45d6 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -261,18 +261,10 @@ class Picking(models.Model): default=lambda self: self.env['res.company']._company_default_get('stock.picking'), index=True, required=True, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) - # TDE FIXME: separate those two kind of pack operations + pack_operation_ids = fields.One2many( 'stock.pack.operation', 'picking_id', 'Related Packing Operations', states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) - pack_operation_product_ids = fields.One2many( - 'stock.pack.operation', 'picking_id', 'Non pack', - domain=[('product_id', '!=', False)], - states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) - pack_operation_pack_ids = fields.One2many( - 'stock.pack.operation', 'picking_id', 'Pack', - domain=[('product_id', '=', False)], - states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) pack_operation_exist = fields.Boolean( 'Has Pack Operations', compute='_compute_pack_operation_exist', @@ -285,16 +277,13 @@ class Picking(models.Model): printed = fields.Boolean('Printed') # Used to search on pickings product_id = fields.Many2one('product.product', 'Product', related='move_lines.product_id') - recompute_pack_op = fields.Boolean( - 'Recompute pack operation?', copy=False, - help='True if reserved quants changed, which mean we might need to recompute the package operations') - launch_pack_operations = fields.Boolean("Launch Pack Operations", copy=False) + show_operations = fields.Boolean(related='picking_type_id.show_operations') _sql_constraints = [ ('name_uniq', 'unique(name, company_id)', 'Reference must be unique per company!'), ] - @api.depends('move_type', 'launch_pack_operations', 'move_lines.state', 'move_lines.picking_id', 'move_lines.partially_available') + @api.depends('move_type', 'move_lines.state', 'move_lines.picking_id') @api.one def _compute_state(self): ''' State of a picking depends on the state of its related stock.move @@ -307,9 +296,8 @@ class Picking(models.Model): - one of the move is assigned or partially available: partially available - otherwise in waiting or confirmed state ''' - if not self.move_lines and self.launch_pack_operations: - self.state = 'assigned' - elif not self.move_lines: + # FIXME: goes in 3 times at each write? + if not self.move_lines: self.state = 'draft' elif any(move.state == 'draft' for move in self.move_lines): # TDE FIXME: should be all ? self.state = 'draft' @@ -318,16 +306,34 @@ class Picking(models.Model): elif all(move.state in ['cancel', 'done'] for move in self.move_lines): self.state = 'done' else: - # We sort our moves by importance of state: "confirmed" should be first, then we'll have - # "waiting" and finally "assigned" at the end. + # We sort our moves by importance of state: + # ------------- 0 + # | Confirmed | + # ------------- + # | Partial | + # ------------- + # | Waiting | + # ------------- + # | Assigned | + # ------------- len - 1 + sort_map = { + 'assigned': 4, + 'waiting': 3, + 'partially_available': 2, + 'confirmed': 1, + } moves_todo = self.move_lines\ .filtered(lambda move: move.state not in ['cancel', 'done'])\ - .sorted(key=lambda move: (move.state == 'assigned' and 2) or (move.state == 'waiting' and 1) or 0) + .sorted(key=lambda move: sort_map.get(move.state, 0)) if self.move_type == 'one': - self.state = moves_todo[0].state or 'draft' - elif moves_todo[0].state != 'assigned' and any(x.partially_available or x.state == 'assigned' for x in moves_todo): + if moves_todo[0].state in ('partially_available', 'confirmed'): + self.state = 'confirmed' + else: + self.state = moves_todo[0].state or 'draft' + elif moves_todo[0].state != 'assigned' and any(x.state in ['assigned', 'partially_available'] for x in moves_todo): self.state = 'partially_available' else: + # take the less important state among all move_lines. self.state = moves_todo[-1].state or 'draft' @api.one @@ -443,10 +449,14 @@ class Picking(models.Model): @api.multi def action_confirm(self): - self.filtered(lambda picking: not picking.move_lines).write({'launch_pack_operations': True}) - # TDE CLEANME: use of launch pack operation, really useful ? - self.mapped('move_lines').filtered(lambda move: move.state == 'draft').action_confirm() - self.filtered(lambda picking: picking.location_id.usage in ('supplier', 'inventory', 'production')).force_assign() + # call `action_confirm` on every draft move + self.mapped('move_lines')\ + .filtered(lambda move: move.state == 'draft')\ + .action_confirm() + # call `action_assign` on every confirmed move which location_id bypasses the reservation + self.filtered(lambda picking: picking.location_id.usage in ('supplier', 'inventory', 'production'))\ + .filtered(lambda move: move.state == 'confirmed')\ + .mapped('move_lines').action_assign() return True @api.multi @@ -468,7 +478,7 @@ class Picking(models.Model): """ Changes state of picking to available if moves are confirmed or waiting. @return: True """ - self.mapped('move_lines').filtered(lambda move: move.state in ['confirmed', 'waiting']).force_assign() + self.mapped('move_lines').filtered(lambda move: move.state in ['confirmed', 'waiting', 'partially_available']).force_assign() return True @api.multi @@ -485,419 +495,157 @@ class Picking(models.Model): """ # TDE FIXME: remove decorator when migration the remaining # TDE FIXME: draft -> automatically done, if waiting ?? CLEAR ME - draft_moves = self.mapped('move_lines').filtered(lambda self: self.state == 'draft') - todo_moves = self.mapped('move_lines').filtered(lambda self: self.state in ['draft', 'assigned', 'confirmed']) - draft_moves.action_confirm() + todo_moves = self.mapped('move_lines').filtered(lambda self: self.state in ['draft', 'partially_available', 'assigned', 'confirmed']) + # Check if there are ops not linked to moves yet + for pick in self: + # # Explode manually added packages + # for ops in pick.pack_operation_ids.filtered(lambda x: not x.move_id and not x.product_id): + # for quant in ops.package_id.quant_ids: #Or use get_content for multiple levels + # self.pack_operation_ids.create({'product_id': quant.product_id.id, + # 'package_id': quant.package_id.id, + # 'result_package_id': ops.result_package_id, + # 'lot_id': quant.lot_id.id, + # 'owner_id': quant.owner_id.id, + # 'product_uom_id': quant.product_id.uom_id.id, + # 'product_qty': quant.qty, + # 'qty_done': quant.qty, + # 'location_id': quant.location_id.id, # Could be ops too + # 'location_dest_id': ops.location_dest_id.id, + # 'picking_id': pick.id + # }) # Might change first element + # # Link existing moves or add moves when no one is related + + for ops in pick.pack_operation_ids.filtered(lambda x: not x.move_id): + # Search move with this product + moves = pick.move_lines.filtered(lambda x: x.product_id == ops.product_id) + if moves: #could search move that needs it the most (that has some quantities left) + ops.move_id = moves[0].id + else: + new_move = self.env['stock.move'].create({ + 'name': _('New Move:') + ops.product_id.display_name, + 'product_id': ops.product_id.id, + 'product_uom_qty': ops.qty_done, + 'product_uom': ops.product_uom_id.id, + 'location_id': pick.location_id.id, + 'location_dest_id': pick.location_dest_id.id, + 'picking_id': pick.id, + }) + ops.move_id = new_move.id + new_move.action_confirm() + todo_moves |= new_move + #'qty_done': ops.qty_done}) todo_moves.action_done() return True - def _prepare_pack_ops(self, quants, forced_qties): - """ Prepare pack_operations, returns a list of dict to give at create """ - # TDE CLEANME: oh dear ... - valid_quants = quants.filtered(lambda quant: quant.qty > 0) - _Mapping = namedtuple('Mapping', ('product', 'package', 'owner', 'location', 'location_dst_id')) - - all_products = valid_quants.mapped('product_id') | self.env['product.product'].browse(p.id for p in forced_qties) | self.move_lines.mapped('product_id') - computed_putaway_locations = dict( - (product, self.location_dest_id.get_putaway_strategy(product).id or self.location_dest_id.id) for product in all_products) - - product_to_uom = dict((product.id, product.uom_id) for product in all_products) - picking_moves = self.move_lines.filtered(lambda move: move.state not in ('done', 'cancel')) - for move in picking_moves: - # If we encounter an UoM that is smaller than the default UoM or the one already chosen, use the new one instead. - if move.product_uom != product_to_uom[move.product_id.id] and move.product_uom.factor > product_to_uom[move.product_id.id].factor: - product_to_uom[move.product_id.id] = move.product_uom - if len(picking_moves.mapped('location_id')) > 1: - raise UserError(_('The source location must be the same for all the moves of the picking.')) - if len(picking_moves.mapped('location_dest_id')) > 1: - raise UserError(_('The destination location must be the same for all the moves of the picking.')) - - pack_operation_values = [] - # find the packages we can move as a whole, create pack operations and mark related quants as done - top_lvl_packages = valid_quants._get_top_level_packages(computed_putaway_locations) - for pack in top_lvl_packages: - pack_quants = pack.get_content() - pack_operation_values.append({ - 'picking_id': self.id, - 'package_id': pack.id, - 'product_qty': 1.0, - 'location_id': pack.location_id.id, - 'location_dest_id': computed_putaway_locations[pack_quants[0].product_id], - 'owner_id': pack.owner_id.id, - }) - valid_quants -= pack_quants - - # Go through all remaining reserved quants and group by product, package, owner, source location and dest location - # Lots will go into pack operation lot object - qtys_grouped = {} - lots_grouped = {} - for quant in valid_quants: - key = _Mapping(quant.product_id, quant.package_id, quant.owner_id, quant.location_id, computed_putaway_locations[quant.product_id]) - qtys_grouped.setdefault(key, 0.0) - qtys_grouped[key] += quant.qty - if quant.product_id.tracking != 'none' and quant.lot_id: - lots_grouped.setdefault(key, dict()).setdefault(quant.lot_id.id, 0.0) - lots_grouped[key][quant.lot_id.id] += quant.qty - # Do the same for the forced quantities (in cases of force_assign or incomming shipment for example) - for product, qty in pycompat.items(forced_qties): - if qty <= 0.0: - continue - key = _Mapping(product, self.env['stock.quant.package'], self.owner_id, self.location_id, computed_putaway_locations[product]) - qtys_grouped.setdefault(key, 0.0) - qtys_grouped[key] += qty - - # Create the necessary operations for the grouped quants and remaining qtys - Uom = self.env['product.uom'] - product_id_to_vals = {} # use it to create operations using the same order as the picking stock moves - for mapping, qty in pycompat.items(qtys_grouped): - uom = product_to_uom[mapping.product.id] - val_dict = { - 'picking_id': self.id, - 'product_qty': mapping.product.uom_id._compute_quantity(qty, uom), - 'product_id': mapping.product.id, - 'package_id': mapping.package.id, - 'owner_id': mapping.owner.id, - 'location_id': mapping.location.id, - 'location_dest_id': mapping.location_dst_id, - 'product_uom_id': uom.id, - 'pack_lot_ids': [ - (0, 0, {'lot_id': lot, 'qty': 0.0, 'qty_todo': lots_grouped[mapping][lot]}) - for lot in lots_grouped.get(mapping, {})], - } - product_id_to_vals.setdefault(mapping.product.id, list()).append(val_dict) - - for move in self.move_lines.filtered(lambda move: move.state not in ('done', 'cancel')): - values = product_id_to_vals.pop(move.product_id.id, []) - pack_operation_values += values - return pack_operation_values + do_transfer = action_done #TODO:replace later @api.multi - def do_prepare_partial(self): - # TDE CLEANME: oh dear ... - PackOperation = self.env['stock.pack.operation'] - - # get list of existing operations and delete them - existing_packages = PackOperation.search([('picking_id', 'in', self.ids)]) # TDE FIXME: o2m / m2o ? - if existing_packages: - existing_packages.unlink() + def _check_entire_pack(self): + """ This function check if entire packs are moved in the picking""" for picking in self: - forced_qties = {} # Quantity remaining after calculating reserved quants - picking_quants = self.env['stock.quant'] - # Calculate packages, reserved quants, qtys of this picking's moves - for move in picking.move_lines: - if move.state not in ('assigned', 'confirmed', 'waiting'): - continue - move_quants = move.reserved_quant_ids - picking_quants += move_quants - forced_qty = 0.0 - if move.state == 'assigned': - qty = move.product_uom._compute_quantity(move.product_uom_qty, move.product_id.uom_id, round=False) - forced_qty = qty - sum([x.qty for x in move_quants]) - # if we used force_assign() on the move, or if the move is incoming, forced_qty > 0 - if float_compare(forced_qty, 0, precision_rounding=move.product_id.uom_id.rounding) > 0: - if forced_qties.get(move.product_id): - forced_qties[move.product_id] += forced_qty - else: - forced_qties[move.product_id] = forced_qty - for vals in picking._prepare_pack_ops(picking_quants, forced_qties): - vals['fresh_record'] = False - PackOperation |= PackOperation.create(vals) - # recompute the remaining quantities all at once - self.do_recompute_remaining_quantities() - for pack in PackOperation: - pack.ordered_qty = sum( - pack.mapped('linked_move_operation_ids').mapped('move_id').filtered(lambda r: r.state != 'cancel').mapped('ordered_qty') - ) - self.write({'recompute_pack_op': False}) + origin_packages = picking.pack_operation_ids.mapped("package_id") + for pack in origin_packages: + all_in = True + packops = picking.pack_operation_ids.filtered(lambda x: x.package_id == pack) + keys = ['product_id', 'lot_id'] + + grouped_quants = {} + for k, g in groupby(sorted(pack.quant_ids, key=itemgetter(*keys)), key=itemgetter(*keys)): + grouped_quants[k] = sum(self.env['stock.quant'].concat(*list(g)).mapped('quantity')) + + grouped_ops = {} + for k, g in groupby(sorted(packops, key=itemgetter(*keys)), key=itemgetter(*keys)): + grouped_ops[k] = sum(self.env['stock.pack.operation'].concat(*list(g)).mapped('product_qty')) + if any(grouped_quants[key] - grouped_ops.get(key, 0) != 0 for key in grouped_quants)\ + or any(grouped_ops[key] - grouped_quants[key] != 0 for key in grouped_ops): + all_in = False + if all_in and packops: + packops.write({'result_package_id': pack.id}) @api.multi def do_unreserve(self): - return True - - def recompute_remaining_qty(self, done_qtys=False): - - def _create_link_for_index(operation_id, index, product_id, qty_to_assign, quant_id=False): - move_dict = prod2move_ids[product_id][index] - qty_on_link = min(move_dict['remaining_qty'], qty_to_assign) - self.env['stock.move.operation.link'].create({'move_id': move_dict['move'].id, 'operation_id': operation_id, 'qty': qty_on_link, 'reserved_quant_id': quant_id}) - if move_dict['remaining_qty'] == qty_on_link: - prod2move_ids[product_id].pop(index) - else: - move_dict['remaining_qty'] -= qty_on_link - return qty_on_link - - def _create_link_for_quant(operation_id, quant, qty): - """create a link for given operation and reserved move of given quant, for the max quantity possible, and returns this quantity""" - if not quant.reservation_id.id: - return _create_link_for_product(operation_id, quant.product_id.id, qty) - qty_on_link = 0 - for i in range(0, len(prod2move_ids[quant.product_id.id])): - if prod2move_ids[quant.product_id.id][i]['move'].id != quant.reservation_id.id: - continue - qty_on_link = _create_link_for_index(operation_id, i, quant.product_id.id, qty, quant_id=quant.id) - break - return qty_on_link - - def _create_link_for_product(operation_id, product_id, qty): - '''method that creates the link between a given operation and move(s) of given product, for the given quantity. - Returns True if it was possible to create links for the requested quantity (False if there was not enough quantity on stock moves)''' - qty_to_assign = qty - Product = self.env["product.product"] - product = Product.browse(product_id) - rounding = product.uom_id.rounding - qtyassign_cmp = float_compare(qty_to_assign, 0.0, precision_rounding=rounding) - if prod2move_ids.get(product_id): - while prod2move_ids[product_id] and qtyassign_cmp > 0: - qty_on_link = _create_link_for_index(operation_id, 0, product_id, qty_to_assign, quant_id=False) - qty_to_assign -= qty_on_link - qtyassign_cmp = float_compare(qty_to_assign, 0.0, precision_rounding=rounding) - return qtyassign_cmp == 0 - - # TDE CLEANME: oh dear ... - Uom = self.env['product.uom'] - QuantPackage = self.env['stock.quant.package'] - OperationLink = self.env['stock.move.operation.link'] - - quants_in_package_done = set() - prod2move_ids = {} - still_to_do = [] - # make a dictionary giving for each product, the moves and related quantity that can be used in operation links - moves = sorted([x for x in self.move_lines if x.state not in ('done', 'cancel')], key=lambda x: (((x.state == 'assigned') and -2 or 0) + (x.partially_available and -1 or 0))) - for move in moves: - if not prod2move_ids.get(move.product_id.id): - prod2move_ids[move.product_id.id] = [{'move': move, 'remaining_qty': move.product_qty}] - else: - prod2move_ids[move.product_id.id].append({'move': move, 'remaining_qty': move.product_qty}) - - need_rereserve = False - # sort the operations in order to give higher priority to those with a package, then a lot/serial number - operations = self.pack_operation_ids - operations = sorted(operations, key=lambda x: ((x.package_id and not x.product_id) and -4 or 0) + (x.package_id and -2 or 0) + (x.pack_lot_ids and -1 or 0)) - # delete existing operations to start again from scratch - links = OperationLink.search([('operation_id', 'in', [x.id for x in operations])]) - if links: - links.unlink() - # 1) first, try to create links when quants can be identified without any doubt - for ops in operations: - lot_qty = {} - for packlot in ops.pack_lot_ids: - lot_qty[packlot.lot_id.id] = ops.product_uom_id._compute_quantity(packlot.qty, ops.product_id.uom_id) - # for each operation, create the links with the stock move by seeking on the matching reserved quants, - # and deffer the operation if there is some ambiguity on the move to select - if ops.package_id and not ops.product_id and (not done_qtys or ops.qty_done): - # entire package - for quant in ops.package_id.get_content(): - remaining_qty_on_quant = quant.qty - if quant.reservation_id: - # avoid quants being counted twice - quants_in_package_done.add(quant.id) - qty_on_link = _create_link_for_quant(ops.id, quant, quant.qty) - remaining_qty_on_quant -= qty_on_link - if remaining_qty_on_quant: - still_to_do.append((ops, quant.product_id.id, remaining_qty_on_quant)) - need_rereserve = True - elif ops.product_id.id: - # Check moves with same product - product_qty = ops.qty_done if done_qtys else ops.product_qty - qty_to_assign = ops.product_uom_id._compute_quantity(product_qty, ops.product_id.uom_id) - precision_rounding = ops.product_id.uom_id.rounding - for move_dict in prod2move_ids.get(ops.product_id.id, []): - move = move_dict['move'] - for quant in move.reserved_quant_ids: - if float_compare(qty_to_assign, 0, precision_rounding=precision_rounding) != 1: - break - if quant.id in quants_in_package_done: - continue - - # check if the quant is matching the operation details - if ops.package_id: - flag = quant.package_id == ops.package_id - else: - flag = not quant.package_id.id - flag = flag and (ops.owner_id.id == quant.owner_id.id) - if flag: - if not lot_qty: - max_qty_on_link = min(quant.qty, qty_to_assign) - qty_on_link = _create_link_for_quant(ops.id, quant, max_qty_on_link) - qty_to_assign -= qty_on_link - else: - if lot_qty.get(quant.lot_id.id): # if there is still some qty left - max_qty_on_link = min(quant.qty, qty_to_assign, lot_qty[quant.lot_id.id]) - qty_on_link = _create_link_for_quant(ops.id, quant, max_qty_on_link) - qty_to_assign -= qty_on_link - lot_qty[quant.lot_id.id] -= qty_on_link - - qty_assign_cmp = float_compare(qty_to_assign, 0, precision_rounding=precision_rounding) - if qty_assign_cmp > 0: - # qty reserved is less than qty put in operations. We need to create a link but it's deferred after we processed - # all the quants (because they leave no choice on their related move and needs to be processed with higher priority) - still_to_do += [(ops, ops.product_id.id, qty_to_assign)] - need_rereserve = True - - # 2) then, process the remaining part - all_op_processed = True - for ops, product_id, remaining_qty in still_to_do: - all_op_processed = _create_link_for_product(ops.id, product_id, remaining_qty) and all_op_processed - return (need_rereserve, all_op_processed) - - def picking_recompute_remaining_quantities(self, done_qtys=False): - need_rereserve = False - all_op_processed = True - if self.pack_operation_ids: - need_rereserve, all_op_processed = self.recompute_remaining_qty(done_qtys=done_qtys) - return need_rereserve, all_op_processed - - def do_recompute_remaining_quantities(self, done_qtys=False): - # TDE FIXME - tmp = self.filtered(lambda picking: picking.pack_operation_ids) - if tmp: - for pick in tmp: - pick.recompute_remaining_qty(done_qtys=done_qtys) - - def rereserve_quants(self, move_ids=[]): - """ Unreserve quants then try to reassign quants.""" - if not move_ids: - self.do_unreserve() - self.action_assign() - else: - moves = self.env['stock.move'].browse(move_ids) - if self.env.context.get('no_state_change'): - moves = moves.filtered(lambda m: m.reserved_quant_ids) - moves.do_unreserve() - moves.action_assign(no_prepare=True) + for move in self: + for move_line in move.move_lines: + move_line.do_unreserve() + self.write({'state': 'confirmed'}) @api.multi - def do_new_transfer(self): - for pick in self: - if pick.state == 'done': - raise UserError(_('The pick is already validated')) - pack_operations_delete = self.env['stock.pack.operation'] - if not pick.move_lines and not pick.pack_operation_ids: - raise UserError(_('Please create some Initial Demand or Mark as Todo and create some Operations. ')) - # In draft or with no pack operations edited yet, ask if we can just do everything - if pick.state == 'draft' or all([x.qty_done == 0.0 for x in pick.pack_operation_ids]): - # If no lots when needed, raise error - picking_type = pick.picking_type_id - if (picking_type.use_create_lots or picking_type.use_existing_lots): - for pack in pick.pack_operation_ids: - if pack.product_id and pack.product_id.tracking != 'none': - raise UserError(_('Some products require lots/serial numbers, so you need to specify those first!')) - view = self.env.ref('stock.view_immediate_transfer') - wiz = self.env['stock.immediate.transfer'].create({'pick_id': pick.id}) - # TDE FIXME: a return in a loop, what a good idea. Really. - return { - 'name': _('Immediate Transfer?'), - 'type': 'ir.actions.act_window', - 'view_type': 'form', - 'view_mode': 'form', - 'res_model': 'stock.immediate.transfer', - 'views': [(view.id, 'form')], - 'view_id': view.id, - 'target': 'new', - 'res_id': wiz.id, - 'context': self.env.context, - } + def button_validate(self): + self.ensure_one() + pack_operations_delete = self.env['stock.pack.operation'] + if not self.move_lines and not self.pack_operation_ids: + raise UserError(_('Please add some lines to move')) + # In draft or with no pack operations edited yet, ask if we can just do everything + if self.state == 'draft' or all([x.qty_done == 0.0 for x in self.pack_operation_ids]): + # If no lots when needed, raise error + picking_type = self.picking_type_id + if (picking_type.use_create_lots or picking_type.use_existing_lots): + for pack in self.pack_operation_ids: + if pack.product_id and pack.product_id.tracking != 'none': + raise UserError(_('Some products require lots/serial numbers, so you need to specify those first!')) + view = self.env.ref('stock.view_immediate_transfer') + wiz = self.env['stock.immediate.transfer'].create({'pick_id': self.id}) + return { + 'name': _('Immediate Transfer?'), + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'stock.immediate.transfer', + 'views': [(view.id, 'form')], + 'view_id': view.id, + 'target': 'new', + 'res_id': wiz.id, + 'context': self.env.context, + } - # Check backorder should check for other barcodes - if pick.check_backorder(): - view = self.env.ref('stock.view_backorder_confirmation') - wiz = self.env['stock.backorder.confirmation'].create({'pick_id': pick.id}) - # TDE FIXME: same reamrk as above actually - return { - 'name': _('Create Backorder?'), - 'type': 'ir.actions.act_window', - 'view_type': 'form', - 'view_mode': 'form', - 'res_model': 'stock.backorder.confirmation', - 'views': [(view.id, 'form')], - 'view_id': view.id, - 'target': 'new', - 'res_id': wiz.id, - 'context': self.env.context, - } - for operation in pick.pack_operation_ids: - if operation.qty_done < 0: - raise UserError(_('No negative quantities allowed')) - if operation.qty_done > 0: - operation.write({'product_qty': operation.qty_done}) - else: - pack_operations_delete |= operation - if pack_operations_delete: - pack_operations_delete.unlink() - self.do_transfer() + # Check backorder should check for other barcodes + if self.check_backorder(): + view = self.env.ref('stock.view_backorder_confirmation') + wiz = self.env['stock.backorder.confirmation'].create({'pick_id': self.id}) + return { + 'name': _('Create Backorder?'), + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'stock.backorder.confirmation', + 'views': [(view.id, 'form')], + 'view_id': view.id, + 'target': 'new', + 'res_id': wiz.id, + 'context': self.env.context, + } + for operation in self.pack_operation_ids: + if operation.qty_done < 0: + raise UserError(_('No negative quantities allowed')) + if operation.qty_done > 0: + pass + #operation.write({'product_qty': operation.qty_done}) + else: + pack_operations_delete |= operation + if pack_operations_delete: + pack_operations_delete.unlink() + self.action_done() return + do_new_transfer = button_validate #TODO: replace later + def check_backorder(self): - need_rereserve, all_op_processed = self.picking_recompute_remaining_quantities(done_qtys=True) + self.ensure_one() + quantity_todo = {} + quantity_done = {} for move in self.move_lines: - if float_compare(move.remaining_qty, 0, precision_rounding=move.product_id.uom_id.rounding) != 0: - return True - return False - - @api.multi - def do_transfer(self): - """ If no pack operation, we do simple action_done of the picking. - Otherwise, do the pack operations. """ - # TDE CLEAN ME: reclean me, please - self._create_lots_for_picking() - - no_pack_op_pickings = self.filtered(lambda picking: not picking.pack_operation_ids) - no_pack_op_pickings.action_done() - other_pickings = self - no_pack_op_pickings - for picking in other_pickings: - need_rereserve, all_op_processed = picking.picking_recompute_remaining_quantities() - todo_moves = self.env['stock.move'] - toassign_moves = self.env['stock.move'] - - # create extra moves in the picking (unexpected product moves coming from pack operations) - if not all_op_processed: - todo_moves |= picking._create_extra_moves() - - if need_rereserve or not all_op_processed: - moves_reassign = any(x.origin_returned_move_id or x.move_orig_ids for x in picking.move_lines if x.state not in ['done', 'cancel']) - if moves_reassign and picking.location_id.usage not in ("supplier", "production", "inventory"): - # unnecessary to assign other quants than those involved with pack operations as they will be unreserved anyways. - picking.with_context(reserve_only_ops=True, no_state_change=True).rereserve_quants(move_ids=picking.move_lines.ids) - picking.do_recompute_remaining_quantities() - - # split move lines if needed - for move in picking.move_lines: - rounding = move.product_id.uom_id.rounding - remaining_qty = move.remaining_qty - if move.state in ('done', 'cancel'): - # ignore stock moves cancelled or already done - continue - elif move.state == 'draft': - toassign_moves |= move - if float_compare(remaining_qty, 0, precision_rounding=rounding) == 0: - if move.state in ('draft', 'assigned', 'confirmed'): - todo_moves |= move - elif float_compare(remaining_qty, 0, precision_rounding=rounding) > 0 and float_compare(remaining_qty, move.product_qty, precision_rounding=rounding) < 0: - # TDE FIXME: shoudl probably return a move - check for no track key, by the way - new_move_id = move.split(remaining_qty) - new_move = self.env['stock.move'].with_context(mail_notrack=True).browse(new_move_id) - todo_moves |= move - # Assign move as it was assigned before - toassign_moves |= new_move - - # TDE FIXME: do_only_split does not seem used anymore - if todo_moves and not self.env.context.get('do_only_split'): - todo_moves.action_done() - elif self.env.context.get('do_only_split'): - picking = picking.with_context(split=todo_moves.ids) - - picking._create_backorder() - return True - - def _create_lots_for_picking(self): - Lot = self.env['stock.production.lot'] - for pack_op_lot in self.mapped('pack_operation_ids').mapped('pack_lot_ids'): - if not pack_op_lot.lot_id: - lot = Lot.create({'name': pack_op_lot.lot_name, 'product_id': pack_op_lot.operation_id.product_id.id}) - pack_op_lot.write({'lot_id': lot.id}) - # TDE FIXME: this should not be done here - self.mapped('pack_operation_ids').mapped('pack_lot_ids').filtered(lambda op_lot: op_lot.qty == 0.0).unlink() - create_lots_for_picking = _create_lots_for_picking + quantity_todo.setdefault(move.product_id.id, 0) + quantity_done.setdefault(move.product_id.id, 0) + quantity_todo[move.product_id.id] += move.product_qty + quantity_done[move.product_id.id] += move.quantity_done #TODO: convert to base units + for ops in self.pack_operation_ids.filtered(lambda x: x.package_id and not x.product_id and not x.move_id): + for quant in ops.package_id.quant_ids: + quantity_done.setdefault(quant.product_id.id, 0) + quantity_done[quant.product_id.id] += quant.qty + for pack in self.pack_operation_ids.filtered(lambda x: x.product_id and not x.move_id): + quantity_done.setdefault(pack.product_id.id, 0) + quantity_done[pack.product_id.id] += pack.qty_done + return any(quantity_done[x] < quantity_todo.get(x, 0) for x in quantity_done) def _create_extra_moves(self): '''This function creates move lines on a picking, at the time of do_transfer, based on @@ -994,13 +742,6 @@ class Picking(models.Model): new_operation = operation.copy({'product_qty': operation.qty_done,'qty_done': operation.qty_done}) operation.write({'product_qty': operation.product_qty - operation.qty_done,'qty_done': 0}) - if operation.pack_lot_ids: - packlots_transfer = [(4, x.id) for x in operation.pack_lot_ids] - new_operation.write({'pack_lot_ids': packlots_transfer}) - - # the stock.pack.operation.lot records now belong to the new, packaged stock.pack.operation - # we have to create new ones with new quantities for our original, unfinished stock.pack.operation - new_operation._copy_remaining_pack_lot_ids(operation) op = new_operation pack_operation_ids |= op @@ -1012,7 +753,6 @@ class Picking(models.Model): raise UserError(_('Please process some quantities to put in the pack first!')) return package - @api.multi def put_in_pack(self): return self._put_in_pack() diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index ea6fb20b962..1f7e44f23ea 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -521,7 +521,9 @@ class Warehouse(models.Model): 'action': 'move', 'picking_type_id': routing.picking_type.id, 'procure_method': first_rule is True and 'make_to_stock' or 'make_to_order', - 'warehouse_id': self.id} + 'warehouse_id': self.id, + 'propagate': routing.picking_type != self.pick_type_id, + } route_pull_values.update(values or {}) route_pull_values.update(pull_values or {}) pull_rules_list.append(route_pull_values) diff --git a/addons/stock/security/ir.model.access.csv b/addons/stock/security/ir.model.access.csv index 6b13597199d..781a2b1156f 100644 --- a/addons/stock/security/ir.model.access.csv +++ b/addons/stock/security/ir.model.access.csv @@ -62,12 +62,6 @@ access_product_putaway_manager,product.putaway all managers,model_product_putawa access_stock_removal_all,product.removal all users,model_product_removal,base.group_user,1,0,0,0 access_stock_fixed_putaway_strat,stock_fixed_putaway_strat managers,model_stock_fixed_putaway_strat,stock.group_stock_manager,1,1,1,1 access_stock_fixed_putaway_user,stock_fixed_putaway_strat user,model_stock_fixed_putaway_strat,stock.group_stock_user,1,0,0,0 -access_stock_move_operation_link_manager,stock.move.operation.link manager,model_stock_move_operation_link,stock.group_stock_manager,1,1,1,1 -access_stock_move_operation_link_user,stock.move.operation.link user,model_stock_move_operation_link,stock.group_stock_user,1,1,1,1 -access_stock_move_operation_link_all,stock.move.operation.link all users,model_stock_move_operation_link,base.group_user,1,1,1,1 -access_stock_pack_operation_lot_manager,stock.pack.operation.lot manager,model_stock_pack_operation_lot,stock.group_stock_manager,1,1,1,1 -access_stock_pack_operation_lot_user,stock.pack.operation.lot user,model_stock_pack_operation_lot,stock.group_stock_user,1,1,1,1 -access_stock_pack_operation_lot_all,stock.pack.operation.lot all users,model_stock_pack_operation_lot,base.group_user,1,1,1,1 access_stock_pack_operation_portal,stock.pack.operation portal,stock.model_stock_pack_operation,base.group_portal,1,0,0,0 access_product_price_history_stock_user,prices.history stock user,product.model_product_price_history,stock.group_stock_user,1,0,0,0 access_product_price_history_stock_manager,prices.history stock manager,product.model_product_price_history,stock.group_stock_manager,1,1,1,1 diff --git a/addons/stock/views/stock_move_views.xml b/addons/stock/views/stock_move_views.xml index 041fbecbe0e..426cf829172 100644 --- a/addons/stock/views/stock_move_views.xml +++ b/addons/stock/views/stock_move_views.xml @@ -178,7 +178,7 @@ - + @@ -222,7 +222,7 @@ - + diff --git a/addons/stock/views/stock_pack_operation_views.xml b/addons/stock/views/stock_pack_operation_views.xml deleted file mode 100644 index cd8469d4672..00000000000 --- a/addons/stock/views/stock_pack_operation_views.xml +++ /dev/null @@ -1,118 +0,0 @@ - - - - stock.pack.operation.details.form - stock.pack.operation - - -
- - - - - - - - - - - - - - - - -
-
-
- - - stock.pack.operation.details.form.save - stock.pack.operation - primary - - - - -
-
-
- -