From a17ef9a7cdda93509aadd7e30a802cc9ffdf8e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Tue, 22 Mar 2016 13:08:47 +0100 Subject: [PATCH] [MOV] stock: stock_move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move models and views are moved in their own file. Impacted:  - stock.move This commit only moves files. No change is done. --- addons/stock/__openerp__.py | 1 + addons/stock/models/__init__.py | 1 + addons/stock/models/stock_move.py | 1133 +++++++++++++++++++++++ addons/stock/stock.py | 1122 ---------------------- addons/stock/stock_view.xml | 367 -------- addons/stock/views/stock_move_views.xml | 372 ++++++++ 6 files changed, 1507 insertions(+), 1489 deletions(-) create mode 100644 addons/stock/models/stock_move.py create mode 100644 addons/stock/views/stock_move_views.xml diff --git a/addons/stock/__openerp__.py b/addons/stock/__openerp__.py index 0beeccad55c..1ebd2d1b4dd 100644 --- a/addons/stock/__openerp__.py +++ b/addons/stock/__openerp__.py @@ -77,6 +77,7 @@ Dashboard / Reports for Inventory Management will include: 'views/stock_quant_views.xml', '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/models/__init__.py b/addons/stock/models/__init__.py index eda29ad9633..75fa920244e 100644 --- a/addons/stock/models/__init__.py +++ b/addons/stock/models/__init__.py @@ -9,6 +9,7 @@ import res_company import res_partner import stock_config_settings import stock_inventory +import stock_move import stock_pack_operation import stock_picking import stock_quant diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py new file mode 100644 index 00000000000..6cd734713cb --- /dev/null +++ b/addons/stock/models/stock_move.py @@ -0,0 +1,1133 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime +from dateutil import relativedelta +import time + +from openerp.osv import fields, osv +from openerp.tools.float_utils import float_compare, float_round +from openerp.tools.translate import _ +from openerp.tools import DEFAULT_SERVER_DATETIME_FORMAT +from openerp import SUPERUSER_ID, api +import openerp.addons.decimal_precision as dp +from openerp.addons.procurement import procurement +from openerp.exceptions import UserError + + +class stock_move(osv.osv): + _name = "stock.move" + _description = "Stock Move" + _order = 'picking_id, sequence, id' + + def get_price_unit(self, cr, uid, ids, context=None): + """ Returns the unit price to store on the quant """ + move = self.browse(cr, uid, ids[0], context=context) + return move.price_unit or move.product_id.standard_price + + def name_get(self, cr, uid, ids, context=None): + res = [] + for line in self.browse(cr, uid, ids, context=context): + name = line.location_id.name + ' > ' + line.location_dest_id.name + if line.product_id.code: + name = line.product_id.code + ': ' + name + if line.picking_id.origin: + name = line.picking_id.origin + '/ ' + name + res.append((line.id, name)) + return res + + def _quantity_normalize(self, cr, uid, ids, name, args, context=None): + uom_obj = self.pool.get('product.uom') + res = {} + for m in self.browse(cr, uid, ids, context=context): + res[m.id] = uom_obj._compute_qty_obj(cr, uid, m.product_uom, m.product_uom_qty, m.product_id.uom_id, context=context) + return res + + def _get_remaining_qty(self, cr, uid, ids, field_name, args, context=None): + uom_obj = self.pool.get('product.uom') + res = {} + for move in self.browse(cr, uid, ids, context=context): + qty = move.product_qty + for record in move.linked_move_operation_ids: + qty -= record.qty + # Keeping in product default UoM + res[move.id] = float_round(qty, precision_rounding=move.product_id.uom_id.rounding) + return res + + def _get_lot_ids(self, cr, uid, ids, field_name, args, context=None): + res = dict.fromkeys(ids, False) + for move in self.browse(cr, uid, ids, context=context): + if move.state == 'done': + res[move.id] = [q.lot_id.id for q in move.quant_ids if q.lot_id] + else: + res[move.id] = [q.lot_id.id for q in move.reserved_quant_ids if q.lot_id] + return res + + def _get_product_availability(self, cr, uid, ids, field_name, args, context=None): + quant_obj = self.pool.get('stock.quant') + res = dict.fromkeys(ids, False) + for move in self.browse(cr, uid, ids, context=context): + if move.state == 'done': + res[move.id] = move.product_qty + else: + sublocation_ids = self.pool.get('stock.location').search(cr, uid, [('id', 'child_of', [move.location_id.id])], context=context) + quant_ids = quant_obj.search(cr, uid, [('location_id', 'in', sublocation_ids), ('product_id', '=', move.product_id.id), ('reservation_id', '=', False)], context=context) + availability = 0 + for quant in quant_obj.browse(cr, uid, quant_ids, context=context): + availability += quant.qty + res[move.id] = min(move.product_qty, availability) + return res + + def _get_string_qty_information(self, cr, uid, ids, field_name, args, context=None): + settings_obj = self.pool.get('stock.config.settings') + uom_obj = self.pool.get('product.uom') + res = dict.fromkeys(ids, '') + precision = self.pool['decimal.precision'].precision_get(cr, uid, 'Product Unit of Measure') + for move in self.browse(cr, uid, ids, context=context): + if move.state in ('draft', 'done', 'cancel') or move.location_id.usage != 'internal': + res[move.id] = '' # 'not applicable' or 'n/a' could work too + continue + total_available = min(move.product_qty, move.reserved_availability + move.availability) + total_available = uom_obj._compute_qty_obj(cr, uid, move.product_id.uom_id, total_available, move.product_uom, round=False, context=context) + total_available = float_round(total_available, precision_digits=precision) + info = str(total_available) + #look in the settings if we need to display the UoM name or not + config_ids = settings_obj.search(cr, uid, [], limit=1, order='id DESC', context=context) + if config_ids: + stock_settings = settings_obj.browse(cr, uid, config_ids[0], context=context) + if stock_settings.group_uom: + info += ' ' + move.product_uom.name + if move.reserved_availability: + if move.reserved_availability != total_available: + #some of the available quantity is assigned and some are available but not reserved + reserved_available = uom_obj._compute_qty_obj(cr, uid, move.product_id.uom_id, move.reserved_availability, move.product_uom, round=False, context=context) + reserved_available = float_round(reserved_available, precision_digits=precision) + info += _(' (%s reserved)') % str(reserved_available) + else: + #all available quantity is assigned + info += _(' (reserved)') + res[move.id] = info + return res + + def _get_reserved_availability(self, cr, uid, ids, field_name, args, context=None): + res = dict.fromkeys(ids, 0) + for move in self.browse(cr, uid, ids, context=context): + res[move.id] = sum([quant.qty for quant in move.reserved_quant_ids]) + return res + + def _set_product_qty(self, cr, uid, id, field, value, arg, context=None): + """ 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`.')) + + _columns = { + 'sequence': fields.integer('Sequence'), + 'name': fields.char('Description', required=True, select=True), + 'priority': fields.selection(procurement.PROCUREMENT_PRIORITIES, 'Priority'), + 'create_date': fields.datetime('Creation Date', readonly=True, select=True), + 'date': fields.datetime('Date', required=True, select=True, help="Move date: scheduled date until move is done, then date of actual move processing", states={'done': [('readonly', True)]}), + 'date_expected': fields.datetime('Expected Date', states={'done': [('readonly', True)]}, required=True, select=True, help="Scheduled date for the processing of this move"), + 'product_id': fields.many2one('product.product', 'Product', required=True, select=True, domain=[('type', 'in', ['product', 'consu'])], states={'done': [('readonly', True)]}), + 'product_qty': fields.function(_quantity_normalize, fnct_inv=_set_product_qty, type='float', digits=0, store={ + _name: (lambda self, cr, uid, ids, c={}: ids, ['product_id', 'product_uom', 'product_uom_qty'], 10), + }, string='Quantity', + help='Quantity in the default UoM of the product'), + 'product_uom_qty': fields.float('Quantity', digits_compute=dp.get_precision('Product Unit of Measure'), + required=True, states={'done': [('readonly', True)]}, + help="This is the quantity of products from an inventory " + "point of view. For moves in the state 'done', this is the " + "quantity of products that were actually moved. For other " + "moves, this is the quantity of product that is planned to " + "be moved. Lowering this quantity does not generate a " + "backorder. Changing this quantity on assigned moves affects " + "the product reservation, and should be done with care." + ), + 'product_uom': fields.many2one('product.uom', 'Unit of Measure', required=True, states={'done': [('readonly', True)]}), + 'product_tmpl_id': fields.related('product_id', 'product_tmpl_id', type='many2one', relation='product.template', string='Product Template'), + + 'product_packaging': fields.many2one('product.packaging', 'preferred Packaging', help="It specifies attributes of packaging like type, quantity of packaging,etc."), + + 'location_id': fields.many2one('stock.location', 'Source Location', required=True, select=True, auto_join=True, + states={'done': [('readonly', True)]}, help="Sets a location if you produce at a fixed location. This can be a partner location if you subcontract the manufacturing operations."), + 'location_dest_id': fields.many2one('stock.location', 'Destination Location', required=True, states={'done': [('readonly', True)]}, select=True, + auto_join=True, help="Location where the system will stock the finished products."), + + 'partner_id': fields.many2one('res.partner', 'Destination Address ', states={'done': [('readonly', True)]}, help="Optional address where goods are to be delivered, specifically used for allotment"), + 'picking_partner_id': fields.related('picking_id', 'partner_id', type='many2one', relation='res.partner', string='Transfer Destination Address'), + + 'move_dest_id': fields.many2one('stock.move', 'Destination Move', help="Optional: next stock move when chaining them", select=True, copy=False), + 'move_orig_ids': fields.one2many('stock.move', 'move_dest_id', 'Original Move', help="Optional: previous stock move when chaining them", select=True), + + 'picking_id': fields.many2one('stock.picking', 'Transfer Reference', select=True, states={'done': [('readonly', True)]}), + 'note': fields.text('Notes'), + 'state': fields.selection([('draft', 'New'), + ('cancel', 'Cancelled'), + ('waiting', 'Waiting Another Move'), + ('confirmed', 'Waiting Availability'), + ('assigned', 'Available'), + ('done', 'Done'), + ], 'Status', readonly=True, select=True, copy=False, + 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', readonly=True, help="Checks if the move has some stock reserved", copy=False), + '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 + + 'company_id': fields.many2one('res.company', 'Company', required=True, select=True), + 'split_from': fields.many2one('stock.move', string="Move Split From", help="Technical field used to track the origin of a split move, which can be useful in case of debug", copy=False), + 'backorder_id': fields.related('picking_id', 'backorder_id', type='many2one', relation="stock.picking", string="Back Order of", select=True), + 'origin': fields.char("Source Document"), + 'procure_method': fields.selection([('make_to_stock', 'Default: Take From Stock'), ('make_to_order', 'Advanced: Apply Procurement Rules')], 'Supply Method', required=True, + help="""By default, the system will take from the stock in the source location and passively wait for availability. The other possibility allows you to directly create a procurement on the source location (and thus ignore 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."""), + + # used for colors in tree views: + 'scrapped': fields.related('location_dest_id', 'scrap_location', type='boolean', relation='stock.location', string='Scrapped', readonly=True), + + 'quant_ids': fields.many2many('stock.quant', 'stock_quant_move_rel', 'move_id', 'quant_id', 'Moved Quants', copy=False), + 'reserved_quant_ids': fields.one2many('stock.quant', 'reservation_id', 'Reserved quants'), + 'linked_move_operation_ids': fields.one2many('stock.move.operation.link', 'move_id', string='Linked Operations', readonly=True, help='Operations that impact this move for the computation of the remaining quantities'), + 'remaining_qty': fields.function(_get_remaining_qty, type='float', string='Remaining Quantity', digits=0, + states={'done': [('readonly', True)]}, help="Remaining Quantity in default UoM according to operations matched with this move"), + 'procurement_id': fields.many2one('procurement.order', 'Procurement'), + 'group_id': fields.many2one('procurement.group', 'Procurement Group'), + 'rule_id': fields.many2one('procurement.rule', 'Procurement Rule', help='The procurement rule that created this stock move'), + 'push_rule_id': fields.many2one('stock.location.path', 'Push Rule', help='The push rule that created this stock move'), + 'propagate': fields.boolean('Propagate cancel and split', help='If checked, when this move is cancelled, cancel the linked move too'), + 'picking_type_id': fields.many2one('stock.picking.type', 'Picking Type'), + 'inventory_id': fields.many2one('stock.inventory', 'Inventory'), + 'lot_ids': fields.function(_get_lot_ids, type='many2many', relation='stock.production.lot', string='Lots'), + 'origin_returned_move_id': fields.many2one('stock.move', 'Origin return move', help='move that created the return move', copy=False), + '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.function(_get_reserved_availability, type='float', string='Quantity Reserved', readonly=True, help='Quantity that has already been reserved for this move'), + 'availability': fields.function(_get_product_availability, type='float', string='Forecasted Quantity', readonly=True, help='Quantity in stock that can still be reserved for this move'), + 'string_availability_info': fields.function(_get_string_qty_information, type='text', string='Availability', readonly=True, help='Show various information on stock availability for this move'), + 'restrict_lot_id': fields.many2one('stock.production.lot', 'Lot', help="Technical field used to depict a restriction on the lot of quants to consider when marking this move as 'done'"), + '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)."), + 'ordered_qty': fields.float('Ordered Quantity', digits_compute=dp.get_precision('Product Unit of Measure')), + } + + def _default_destination_address(self, cr, uid, context=None): + return False + + def _default_group_id(self, cr, uid, context=None): + context = context or {} + if context.get('default_picking_id', False): + picking = self.pool.get('stock.picking').browse(cr, uid, context['default_picking_id'], context=context) + return picking.group_id.id + return False + + _defaults = { + 'partner_id': _default_destination_address, + 'state': 'draft', + 'priority': '1', + 'product_uom_qty': 1.0, + 'sequence': 10, + 'scrapped': False, + 'date': fields.datetime.now, + 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'stock.move', context=c), + 'date_expected': fields.datetime.now, + 'procure_method': 'make_to_stock', + 'propagate': True, + 'partially_available': False, + 'group_id': _default_group_id, + } + + def _check_uom(self, cr, uid, ids, context=None): + for move in self.browse(cr, uid, ids, context=context): + if move.product_id.uom_id.category_id.id != move.product_uom.category_id.id: + return False + return True + + _constraints = [ + (_check_uom, + 'You try to move a product using a UoM that is not compatible with the UoM of the product moved. Please use an UoM in the same UoM category.', + ['product_uom']), + ] + def init(self, cr): + cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('stock_move_product_location_index',)) + if not cr.fetchone(): + cr.execute('CREATE INDEX stock_move_product_location_index ON stock_move (product_id, location_id, location_dest_id, company_id, state)') + + def get_removal_strategy(self, cr, uid, ids, context=None): + ''' Returns the removal strategy to consider for the given move/ops + :rtype: char + ''' + move = self.browse(cr, uid, ids[0], context=context) + product = move.product_id + location = move.location_id + if product.categ_id.removal_strategy_id: + return product.categ_id.removal_strategy_id.method + loc = location + while loc: + if loc.removal_strategy_id: + return loc.removal_strategy_id.method + loc = loc.location_id + return 'fifo' + + @api.cr_uid_ids_context + def do_unreserve(self, cr, uid, move_ids, context=None): + for move in self.browse(cr, uid, move_ids, context=context): + if move.state in ('done', 'cancel'): + raise UserError(_('Cannot unreserve a done move')) + move.quants_unreserve() + if self.find_move_ancestors(cr, uid, [move.id], context=context): + self.write(cr, uid, [move.id], {'state': 'waiting'}, context=context) + else: + self.write(cr, uid, [move.id], {'state': 'confirmed'}, context=context) + + def _push_apply(self, cr, uid, ids, context=None): + push_obj = self.pool.get("stock.location.path") + for move in self.browse(cr, uid, ids, context=context): + #1) if the move is already chained, there is no need to check push rules + #2) 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) + if not move.move_dest_id: + domain = [('location_from_id', '=', move.location_dest_id.id)] + #priority goes to the route defined on the product and product category + route_ids = [x.id for x in move.product_id.route_ids + move.product_id.categ_id.total_route_ids] + rules = push_obj.search(cr, uid, domain + [('route_id', 'in', route_ids)], order='route_sequence, sequence', context=context) + if not rules: + #then we search on the warehouse if a rule can apply + wh_route_ids = [] + if move.warehouse_id: + wh_route_ids = [x.id for x in move.warehouse_id.route_ids] + elif move.picking_id.picking_type_id.warehouse_id: + wh_route_ids = [x.id for x in move.picking_id.picking_type_id.warehouse_id.route_ids] + if wh_route_ids: + rules = push_obj.search(cr, uid, domain + [('route_id', 'in', wh_route_ids)], order='route_sequence, sequence', context=context) + if not rules: + #if no specialized push rule has been found yet, we try to find a general one (without route) + rules = push_obj.search(cr, uid, domain + [('route_id', '=', False)], order='sequence', context=context) + if rules: + rule = push_obj.browse(cr, uid, rules[0], context=context) + # Make sure it is not returning the return + if (not move.origin_returned_move_id or move.origin_returned_move_id.location_id.id != rule.location_dest_id.id): + push_obj._apply(cr, uid, [rule.id], move, context=context) + return True + + def _prepare_procurement_from_move(self, cr, uid, ids, context=None): + move = self.browse(cr, uid, ids, context=context)[0] + origin = (move.group_id and (move.group_id.name + ":") or "") + (move.rule_id and move.rule_id.name or move.origin or move.picking_id.name or "/") + group_id = move.group_id and move.group_id.id or False + if move.rule_id: + if move.rule_id.group_propagation_option == 'fixed' and move.rule_id.group_id: + group_id = move.rule_id.group_id.id + elif move.rule_id.group_propagation_option == 'none': + group_id = False + return { + 'name': move.rule_id and move.rule_id.name or "/", + 'origin': origin, + 'company_id': move.company_id and move.company_id.id or False, + 'date_planned': move.date, + 'product_id': move.product_id.id, + 'product_qty': move.product_uom_qty, + 'product_uom': move.product_uom.id, + 'location_id': move.location_id.id, + 'move_dest_id': move.id, + 'group_id': group_id, + 'route_ids': [(4, x.id) for x in move.route_ids], + 'warehouse_id': move.warehouse_id.id or (move.picking_type_id and move.picking_type_id.warehouse_id.id or False), + 'priority': move.priority, + } + + def _create_procurements(self, cr, uid, ids, context=None): + res = [] + for move in self.browse(cr, uid, ids, context=context): + res.append(self.pool.get("procurement.order").create(cr, uid, move._prepare_procurement_from_move(), context=context)) + # Run procurements immediately when generated from multiple moves + self.pool['procurement.order'].run(cr, uid, res, context=context) + return res + + def create(self, cr, uid, vals, context=None): + if context is None: + context = {} + picking_obj = self.pool['stock.picking'] + track = not context.get('mail_notrack') and vals.get('picking_id') + if track: + picking = picking_obj.browse(cr, uid, vals['picking_id'], context=context) + initial_values = {picking.id: {'state': picking.state}} + vals['ordered_qty'] = vals.get('product_uom_qty') + res = super(stock_move, self).create(cr, uid, vals, context=context) + if track: + picking_obj.message_track(cr, uid, [vals['picking_id']], picking_obj.fields_get(cr, uid, ['state'], context=context), initial_values, context=context) + return res + + def write(self, cr, uid, ids, vals, context=None): + if context is None: + context = {} + if isinstance(ids, (int, long)): + ids = [ids] + picking_obj = self.pool['stock.picking'] + # Check that we do not modify a stock.move which is done + frozen_fields = set(['product_qty', 'product_uom', 'location_id', 'location_dest_id', 'product_id']) + moves = self.browse(cr, uid, ids, context=context) + for move in moves: + if move.state == 'done': + if frozen_fields.intersection(vals): + raise UserError(_('Quantities, Units of Measure, Products and Locations cannot be modified on stock moves that have already been processed (except by the Administrator).')) + propagated_changes_dict = {} + #propagation of quantity change + if vals.get('product_uom_qty'): + propagated_changes_dict['product_uom_qty'] = vals['product_uom_qty'] + if vals.get('product_uom_id'): + propagated_changes_dict['product_uom_id'] = vals['product_uom_id'] + if vals.get('product_uos_qty'): + propagated_changes_dict['product_uos_qty'] = vals['product_uos_qty'] + if vals.get('product_uos_id'): + propagated_changes_dict['product_uos_id'] = vals['product_uos_id'] + #propagation of expected date: + propagated_date_field = False + if vals.get('date_expected'): + #propagate any manual change of the expected date + propagated_date_field = 'date_expected' + elif (vals.get('state', '') == 'done' and vals.get('date')): + #propagate also any delta observed when setting the move as done + propagated_date_field = 'date' + + if not context.get('do_not_propagate', False) and (propagated_date_field or propagated_changes_dict): + #any propagation is (maybe) needed + for move in self.browse(cr, uid, ids, context=context): + if move.move_dest_id and move.propagate: + if 'date_expected' in propagated_changes_dict: + propagated_changes_dict.pop('date_expected') + if propagated_date_field: + current_date = datetime.strptime(move.date_expected, DEFAULT_SERVER_DATETIME_FORMAT) + new_date = datetime.strptime(vals.get(propagated_date_field), DEFAULT_SERVER_DATETIME_FORMAT) + delta = new_date - current_date + if abs(delta.days) >= move.company_id.propagation_minimum_delta: + old_move_date = datetime.strptime(move.move_dest_id.date_expected, DEFAULT_SERVER_DATETIME_FORMAT) + new_move_date = (old_move_date + relativedelta.relativedelta(days=delta.days or 0)).strftime(DEFAULT_SERVER_DATETIME_FORMAT) + propagated_changes_dict['date_expected'] = new_move_date + #For pushed moves as well as for pulled moves, propagate by recursive call of write(). + #Note that, for pulled moves we intentionally don't propagate on the procurement. + if propagated_changes_dict: + self.write(cr, uid, [move.move_dest_id.id], propagated_changes_dict, context=context) + track_pickings = not context.get('mail_notrack') and any(field in vals for field in ['state', 'picking_id', 'partially_available']) + if track_pickings: + to_track_picking_ids = set([move.picking_id.id for move in moves if move.picking_id]) + if vals.get('picking_id'): + to_track_picking_ids.add(vals['picking_id']) + to_track_picking_ids = list(to_track_picking_ids) + pickings = picking_obj.browse(cr, uid, to_track_picking_ids, context=context) + initial_values = dict((picking.id, {'state': picking.state}) for picking in pickings) + res = super(stock_move, self).write(cr, uid, ids, vals, context=context) + if track_pickings: + picking_obj.message_track(cr, uid, to_track_picking_ids, picking_obj.fields_get(cr, uid, ['state'], context=context), initial_values, context=context) + return res + + def onchange_quantity(self, cr, uid, ids, product_id, product_qty, product_uom): + """ On change of product quantity finds UoM + @param product_id: Product id + @param product_qty: Changed Quantity of product + @param product_uom: Unit of measure of product + @return: Dictionary of values + """ + warning = {} + result = {} + + if (not product_id) or (product_qty <= 0.0): + result['product_qty'] = 0.0 + return {'value': result} + + product_obj = self.pool.get('product.product') + # Warn if the quantity was decreased + if ids: + for move in self.read(cr, uid, ids, ['product_qty']): + if product_qty < move['product_qty']: + warning.update({ + 'title': _('Information'), + 'message': _("By changing this quantity here, you accept the " + "new quantity as complete: Odoo will not " + "automatically generate a back order.")}) + break + return {'warning': warning} + + def onchange_product_id(self, cr, uid, ids, prod_id=False, loc_id=False, loc_dest_id=False, partner_id=False): + """ On change of product id, if finds UoM, quantity + @param prod_id: Changed Product id + @param loc_id: Source location id + @param loc_dest_id: Destination location id + @param partner_id: Address id of partner + @return: Dictionary of values + """ + if not prod_id: + return {'domain': {'product_uom': []}} + user = self.pool.get('res.users').browse(cr, uid, uid) + lang = user and user.lang or False + if partner_id: + addr_rec = self.pool.get('res.partner').browse(cr, uid, partner_id) + if addr_rec: + lang = addr_rec and addr_rec.lang or False + ctx = {'lang': lang} + + product = self.pool.get('product.product').browse(cr, uid, [prod_id], context=ctx)[0] + result = { + 'name': product.partner_ref, + 'product_uom': product.uom_id.id, + 'product_uom_qty': 1.00, + } + if loc_id: + result['location_id'] = loc_id + if loc_dest_id: + result['location_dest_id'] = loc_dest_id + res = {'value': result, + 'domain': {'product_uom': [('category_id', '=', product.uom_id.category_id.id)]} + } + return res + + def _prepare_picking_assign(self, cr, uid, ids, context=None): + """ Prepares a new picking for this move as it could not be assigned to + another picking. This method is designed to be inherited. + """ + move = self.browse(cr, uid, ids, context=context)[0] + values = { + 'origin': move.origin, + 'company_id': move.company_id and move.company_id.id or False, + 'move_type': move.group_id and move.group_id.move_type or 'direct', + 'partner_id': move.partner_id.id or False, + 'picking_type_id': move.picking_type_id and move.picking_type_id.id or False, + 'location_id': move.location_id.id, + 'location_dest_id': move.location_dest_id.id, + } + return values + + @api.cr_uid_ids_context + def _picking_assign(self, cr, uid, move_ids, context=None): + """Try to assign the moves to an existing picking + that has not been reserved yet and has the same + procurement group, locations and picking type (moves should already have them identical) + Otherwise, create a new picking to assign them to. + """ + move = self.browse(cr, uid, move_ids, context=context)[0] + pick_obj = self.pool.get("stock.picking") + picks = pick_obj.search(cr, uid, [ + ('group_id', '=', move.group_id.id), + ('location_id', '=', move.location_id.id), + ('location_dest_id', '=', move.location_dest_id.id), + ('picking_type_id', '=', move.picking_type_id.id), + ('printed', '=', False), + ('state', 'in', ['draft', 'confirmed', 'waiting', 'partially_available', 'assigned'])], limit=1, context=context) + if picks: + pick = picks[0] + else: + values = move._prepare_picking_assign() + pick = pick_obj.create(cr, uid, values, context=context) + return self.write(cr, uid, move_ids, {'picking_id': pick}, context=context) + + def onchange_date(self, cr, uid, ids, date, date_expected, context=None): + """ On change of Scheduled Date gives a Move date. + @param date_expected: Scheduled Date + @param date: Move Date + @return: Move Date + """ + if not date_expected: + date_expected = time.strftime(DEFAULT_SERVER_DATETIME_FORMAT) + return {'value': {'date': date_expected}} + + def attribute_price(self, cr, uid, ids, context=None): + """ + Attribute price to move, important in inter-company moves or receipts with only one partner + """ + move = self.browse(cr, uid, ids[0], context=context) + if not move.price_unit: + price = move.product_id.standard_price + self.write(cr, uid, [move.id], {'price_unit': price}) + + def action_confirm(self, cr, uid, ids, context=None): + """ Confirms stock move or put it in waiting if it's linked to another move. + @return: List of ids. + """ + if not context: + context = {} + if isinstance(ids, (int, long)): + ids = [ids] + states = { + 'confirmed': [], + 'waiting': [] + } + to_assign = {} + for move in self.browse(cr, uid, ids, context=context): + self.attribute_price(cr, uid, [move.id], context=context) + state = 'confirmed' + #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: + state = 'waiting' + #if the move is split and some of the ancestor was preceeded, then it's waiting as well + elif move.split_from: + move2 = move.split_from + while move2 and state != 'waiting': + if move2.move_orig_ids: + state = 'waiting' + move2 = move2.split_from + states[state].append(move.id) + + 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: + to_assign[key] = [] + to_assign[key].append(move.id) + moves = [move for move in self.browse(cr, uid, states['confirmed'], context=context) if move.procure_method == 'make_to_order'] + self._create_procurements(cr, uid, [move.id for move in moves], context=context) + for move in moves: + states['waiting'].append(move.id) + states['confirmed'].remove(move.id) + + for state, write_ids in states.items(): + if len(write_ids): + self.write(cr, uid, write_ids, {'state': state}, context=context) + #assign picking in batch for all confirmed move that share the same details + for key, move_ids in to_assign.items(): + self._picking_assign(cr, uid, move_ids, context=context) + moves = self.browse(cr, uid, ids, context=context) + moves._push_apply() + return ids + + def force_assign(self, cr, uid, ids, context=None): + """ Changes the state to assigned. + @return: True + """ + res = self.write(cr, uid, ids, {'state': 'assigned'}, context=context) + self.check_recompute_pack_op(cr, uid, ids, context=context) + return res + + def check_tracking(self, cr, uid, ids, ops, context=None): + """ Checks if serial number is assigned to stock move or not and raise an error if it had to. + """ + move = self.browse(cr, uid, ids[0], context=context) + if move.picking_id and (move.picking_id.picking_type_id.use_existing_lots or move.picking_id.picking_type_id.use_create_lots) and \ + move.product_id.tracking != 'none': + if not (move.restrict_lot_id or (ops and (ops.product_id and ops.pack_lot_ids)) or (ops and not ops.product_id)): + raise UserError(_('You need to provide a Lot/Serial Number for product %s') % move.product_id.name) + + def check_recompute_pack_op(self, cr, uid, ids, context=None): + pickings = list(set([x.picking_id for x in self.browse(cr, uid, ids, context=context) if x.picking_id])) + pickings_partial = [] + pickings_write = [] + pick_obj = self.pool['stock.picking'] + for pick in pickings: + if pick.state in ('waiting', 'confirmed'): #In case of 'all at once' delivery method it should not prepare pack operations + continue + # Check if someone was treating the picking already + if not any([x.qty_done > 0 for x in pick.pack_operation_ids]): + pickings_partial.append(pick.id) + else: + pickings_write.append(pick.id) + if pickings_partial: + pick_obj.do_prepare_partial(cr, uid, pickings_partial, context=context) + if pickings_write: + pick_obj.write(cr, uid, pickings_write, {'recompute_pack_op': True}, context=context) + + def action_assign(self, cr, uid, ids, no_prepare=False, context=None): + """ Checks the product type and accordingly writes the state. + """ + context = context or {} + quant_obj = self.pool.get("stock.quant") + uom_obj = self.pool['product.uom'] + to_assign_moves = set() + main_domain = {} + todo_moves = [] + operations = set() + self.do_unreserve(cr, uid, [x.id for x in self.browse(cr, uid, ids, context=context) if x.reserved_quant_ids and x.state in ['confirmed', 'waiting', 'assigned']], context=context) + for move in self.browse(cr, uid, ids, context=context): + if move.state not in ('confirmed', 'waiting', 'assigned'): + continue + if move.location_id.usage in ('supplier', 'inventory', 'production'): + to_assign_moves.add(move.id) + #in case the move is returned, we want to try to find quants before forcing the assignment + if not move.origin_returned_move_id: + continue + if move.product_id.type == 'consu': + to_assign_moves.add(move.id) + continue + else: + todo_moves.append(move) + + #we always search for yet unassigned quants + main_domain[move.id] = [('reservation_id', '=', False), ('qty', '>', 0)] + + #if the move is preceeded, restrict the choice of quants in the ones moved previously in original move + ancestors = self.find_move_ancestors(cr, uid, [move.id], context=context) + if move.state == 'waiting' and not ancestors: + #if the waiting move hasn't yet any ancestor (PO/MO not confirmed yet), don't find any quant available in stock + main_domain[move.id] += [('id', '=', False)] + elif ancestors: + main_domain[move.id] += [('history_ids', 'in', ancestors)] + + #if the move is returned from another, restrict the choice of quants to the ones that follow the returned move + if move.origin_returned_move_id: + main_domain[move.id] += [('history_ids', 'in', move.origin_returned_move_id.id)] + for link in move.linked_move_operation_ids: + operations.add(link.operation_id) + # Check all ops and sort them: we want to process first the packages, then operations with lot then the rest + operations = list(operations) + operations.sort(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 ops in operations: + #first try to find quants based on specific domains given by linked operations for the case where we want to rereserve according to existing pack operations + if not (ops.product_id and ops.pack_lot_ids): + for record in ops.linked_move_operation_ids: + move = record.move_id + if move.id in main_domain: + qty = record.qty + domain = main_domain[move.id] + if qty: + quants = quant_obj.quants_get_preferred_domain(cr, uid, qty, move, ops=ops, domain=domain, preferred_domain_list=[], context=context) + quant_obj.quants_reserve(cr, uid, quants, move, record, context=context) + else: + lot_qty = {} + rounding = ops.product_id.uom_id.rounding + for pack_lot in ops.pack_lot_ids: + lot_qty[pack_lot.lot_id.id] = uom_obj._compute_qty(cr, uid, ops.product_uom_id.id, pack_lot.qty, ops.product_id.uom_id.id) + for record in ops.linked_move_operation_ids: + move_qty = record.qty + move = record.move_id + domain = main_domain[move.id] + for lot in lot_qty: + if float_compare(lot_qty[lot], 0, precision_rounding=rounding) > 0 and float_compare(move_qty, 0, precision_rounding=rounding) > 0: + qty = min(lot_qty[lot], move_qty) + quants = quant_obj.quants_get_preferred_domain(cr, uid, qty, move, ops=ops, lot_id=lot, domain=domain, preferred_domain_list=[], context=context) + quant_obj.quants_reserve(cr, uid, quants, move, record, context=context) + lot_qty[lot] -= qty + move_qty -= qty + + for move in todo_moves: + #then if the move isn't totally assigned, try to find quants without any specific domain + if move.state != 'assigned': + qty_already_assigned = move.reserved_availability + qty = move.product_qty - qty_already_assigned + quants = quant_obj.quants_get_preferred_domain(cr, uid, qty, move, domain=main_domain[move.id], preferred_domain_list=[], context=context) + quant_obj.quants_reserve(cr, uid, quants, move, context=context) + + #force assignation of consumable products and incoming from supplier/inventory/production + # Do not take force_assign as it would create pack operations + if to_assign_moves: + self.write(cr, uid, list(to_assign_moves), {'state': 'assigned'}, context=context) + if not no_prepare: + self.check_recompute_pack_op(cr, uid, ids, context=context) + + def action_cancel(self, cr, uid, ids, context=None): + """ Cancels the moves and if all moves are cancelled it cancels the picking. + @return: True + """ + procurement_obj = self.pool.get('procurement.order') + context = context or {} + procs_to_check = set() + for move in self.browse(cr, uid, ids, context=context): + if move.state == 'done': + raise UserError(_('You cannot cancel a stock move that has been set to \'Done\'.')) + if move.reserved_quant_ids: + move.quants_unreserve() + if context.get('cancel_procurement'): + if move.propagate: + procurement_ids = procurement_obj.search(cr, uid, [('move_dest_id', '=', move.id)], context=context) + procurement_obj.cancel(cr, uid, procurement_ids, context=context) + else: + if move.move_dest_id: + if move.propagate: + self.action_cancel(cr, uid, [move.move_dest_id.id], context=context) + elif move.move_dest_id.state == 'waiting': + #If waiting, the chain will be broken and we are not sure if we can still wait for it (=> could take from stock instead) + self.write(cr, uid, [move.move_dest_id.id], {'state': 'confirmed'}, context=context) + if move.procurement_id: + # Does the same as procurement check, only eliminating a refresh + procs_to_check.add(move.procurement_id.id) + + res = self.write(cr, uid, ids, {'state': 'cancel', 'move_dest_id': False}, context=context) + if procs_to_check: + procurement_obj.check(cr, uid, list(procs_to_check), context=context) + return res + + def _check_package_from_moves(self, cr, uid, ids, context=None): + pack_obj = self.pool.get("stock.quant.package") + packs = set() + for move in self.browse(cr, uid, ids, context=context): + packs |= set([q.package_id.id for q in move.quant_ids if q.package_id and q.qty > 0]) + return pack_obj._check_location_constraint(cr, uid, list(packs), context=context) + + def find_move_ancestors(self, cr, uid, ids, context=None): + '''Find the first level ancestors of given move ''' + move = self.browse(cr, uid, ids[0], context=context) + ancestors = [] + move2 = move + while move2: + ancestors += [x.id for x in move2.move_orig_ids] + #loop on the split_from to find the ancestor of split moves only if the move has not direct ancestor (priority goes to them) + move2 = not move2.move_orig_ids and move2.split_from or False + return ancestors + + @api.cr_uid_ids_context + def recalculate_move_state(self, cr, uid, move_ids, context=None): + '''Recompute the state of moves given because their reserved quants were used to fulfill another operation''' + for move in self.browse(cr, uid, move_ids, context=context): + vals = {} + reserved_quant_ids = move.reserved_quant_ids + if len(reserved_quant_ids) > 0 and not move.partially_available: + vals['partially_available'] = True + if len(reserved_quant_ids) == 0 and move.partially_available: + vals['partially_available'] = False + if move.state == 'assigned': + if self.find_move_ancestors(cr, uid, [move.id], context=context): + vals['state'] = 'waiting' + else: + vals['state'] = 'confirmed' + if vals: + self.write(cr, uid, [move.id], vals, context=context) + + def _move_quants_by_lot(self, cr, uid, ops, lot_qty, quants_taken, false_quants, lot_move_qty, quant_dest_package_id, context=None): + """ + This function is used to process all the pack operation lots of a pack operation + For every move: + First, we check the quants with lot already reserved (and those are already subtracted from the lots to do) + Then go through all the lots to process: + Add reserved false lots lot by lot + Check if there are not reserved quants or reserved elsewhere with that lot or without lot (with the traditional method) + """ + quant_obj = self.pool['stock.quant'] + fallback_domain = [('reservation_id', '=', False)] + fallback_domain2 = ['&', ('reservation_id', 'not in', [x for x in lot_move_qty.keys()]), ('reservation_id', '!=', False)] + preferred_domain_list = [fallback_domain] + [fallback_domain2] + rounding = ops.product_id.uom_id.rounding + for move in lot_move_qty: + move_quants_dict = {} + move_rec = self.pool['stock.move'].browse(cr, uid, move, context=context) + # Assign quants already reserved with lot to the correct + for quant in quants_taken: + move_quants_dict.setdefault(quant[0].lot_id.id, []) + move_quants_dict[quant[0].lot_id.id] += [quant] + false_quants_move = [x for x in false_quants if x[0].reservation_id.id == move] + for lot in lot_qty: + move_quants_dict.setdefault(lot, []) + redo_false_quants = False + # Take remaining reserved quants with no lot first + # (This will be used mainly when incoming had no lot and you do outgoing with) + while false_quants_move and float_compare(lot_qty[lot], 0, precision_rounding=rounding) > 0 and float_compare(lot_move_qty[move], 0, precision_rounding=rounding) > 0: + qty_min = min(lot_qty[lot], lot_move_qty[move]) + if false_quants_move[0].qty > qty_min: + move_quants_dict[lot] += [(false_quants_move[0], qty_min)] + qty = qty_min + redo_false_quants = True + else: + qty = false_quants_move[0].qty + move_quants_dict[lot] += [(false_quants_move[0], qty)] + false_quants_move.pop(0) + lot_qty[lot] -= qty + lot_move_qty[move] -= qty + + # Search other with first matching lots and then without lots + if float_compare(lot_move_qty[move], 0, precision_rounding=rounding) > 0 and float_compare(lot_qty[lot], 0, precision_rounding=rounding) > 0: + # Search if we can find quants with that lot + domain = [('qty', '>', 0)] + qty = min(lot_qty[lot], lot_move_qty[move]) + quants = quant_obj.quants_get_preferred_domain(cr, uid, qty, move_rec, ops=ops, lot_id=lot, domain=domain, + preferred_domain_list=preferred_domain_list, context=context) + move_quants_dict[lot] += quants + lot_qty[lot] -= qty + lot_move_qty[move] -= qty + + #Move all the quants related to that lot/move + if move_quants_dict[lot]: + quant_obj.quants_move(cr, uid, move_quants_dict[lot], move_rec, ops.location_dest_id, location_from=ops.location_id, + lot_id=lot, owner_id=ops.owner_id.id, src_package_id=ops.package_id.id, + dest_package_id=quant_dest_package_id, context=context) + if redo_false_quants: + move_rec = self.pool['stock.move'].browse(cr, uid, move, context=context) + false_quants_move = [x for x in move_rec.reserved_quant_ids if (not x.lot_id) and (x.owner_id.id == ops.owner_id.id) \ + and (x.location_id.id == ops.location_id.id) and (x.package_id.id != ops.package_id.id)] + + def action_done(self, cr, uid, ids, context=None): + """ Process completely the moves given as ids and if all moves are done, it will finish the picking. + """ + context = context or {} + picking_obj = self.pool.get("stock.picking") + quant_obj = self.pool.get("stock.quant") + uom_obj = self.pool.get("product.uom") + todo = [move.id for move in self.browse(cr, uid, ids, context=context) if move.state == "draft"] + if todo: + ids = self.action_confirm(cr, uid, todo, context=context) + pickings = set() + procurement_ids = set() + #Search operations that are linked to the moves + operations = set() + move_qty = {} + for move in self.browse(cr, uid, ids, context=context): + move_qty[move.id] = move.product_qty + for link in move.linked_move_operation_ids: + operations.add(link.operation_id) + + #Sort operations according to entire packages first, then package + lot, package only, lot only + operations = list(operations) + operations.sort(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 ops in operations: + if ops.picking_id: + pickings.add(ops.picking_id.id) + entire_pack=False + if ops.product_id: + #If a product is given, the result is always put immediately in the result package (if it is False, they are without package) + quant_dest_package_id = ops.result_package_id.id + else: + # When a pack is moved entirely, the quants should not be written anything for the destination package + quant_dest_package_id = False + entire_pack=True + lot_qty = {} + tot_qty = 0.0 + for pack_lot in ops.pack_lot_ids: + qty = uom_obj._compute_qty(cr, uid, ops.product_uom_id.id, pack_lot.qty, ops.product_id.uom_id.id) + lot_qty[pack_lot.lot_id.id] = qty + tot_qty += pack_lot.qty + if ops.pack_lot_ids and ops.product_id and float_compare(tot_qty, ops.product_qty, precision_rounding=ops.product_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 = {} + #Group links by move first + move_qty_ops = {} + for record in ops.linked_move_operation_ids: + move = record.move_id + if not move_qty_ops.get(move): + move_qty_ops[move] = record.qty + else: + move_qty_ops[move] += record.qty + #Process every move only once for every pack operation + for move in move_qty_ops: + main_domain = [('qty', '>', 0)] + self.check_tracking(cr, uid, [move.id], ops, context=context) + preferred_domain = [('reservation_id', '=', move.id)] + fallback_domain = [('reservation_id', '=', False)] + fallback_domain2 = ['&', ('reservation_id', '!=', move.id), ('reservation_id', '!=', False)] + if not ops.pack_lot_ids: + preferred_domain_list = [preferred_domain] + [fallback_domain] + [fallback_domain2] + quants = quant_obj.quants_get_preferred_domain(cr, uid, move_qty_ops[move], move, ops=ops, domain=main_domain, + preferred_domain_list=preferred_domain_list, context=context) + quant_obj.quants_move(cr, uid, quants, move, ops.location_dest_id, location_from=ops.location_id, + lot_id=False, owner_id=ops.owner_id.id, src_package_id=ops.package_id.id, + dest_package_id=quant_dest_package_id, entire_pack=entire_pack, context=context) + else: + # Check what you can do with reserved quants already + qty_on_link = move_qty_ops[move] + rounding = ops.product_id.uom_id.rounding + for reserved_quant in move.reserved_quant_ids: + if (reserved_quant.owner_id.id != ops.owner_id.id) or (reserved_quant.location_id.id != ops.location_id.id) or \ + (reserved_quant.package_id.id != ops.package_id.id): + continue + if not reserved_quant.lot_id: + false_quants += [reserved_quant] + elif float_compare(lot_qty.get(reserved_quant.lot_id.id, 0), 0, precision_rounding=rounding) > 0: + if float_compare(lot_qty[reserved_quant.lot_id.id], reserved_quant.qty, precision_rounding=rounding) >= 0: + lot_qty[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_qty[reserved_quant.lot_id.id])] + lot_qty[reserved_quant.lot_id.id] = 0 + qty_on_link -= lot_qty[reserved_quant.lot_id.id] + lot_move_qty[move.id] = qty_on_link + + if not 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)) + move_qty[move.id] -= move_qty_ops[move] + + #Handle lots separately + if ops.pack_lot_ids: + self._move_quants_by_lot(cr, uid, ops, lot_qty, quants_taken, false_quants, lot_move_qty, quant_dest_package_id, context=context) + + # Handle pack in pack + if not ops.product_id and ops.package_id and ops.result_package_id.id != ops.package_id.parent_id.id: + self.pool.get('stock.quant.package').write(cr, SUPERUSER_ID, [ops.package_id.id], {'parent_id': ops.result_package_id.id}, context=context) + #Check for remaining qtys and unreserve/check move_dest_id in + move_dest_ids = set() + for move in self.browse(cr, uid, ids, context=context): + move_qty_cmp = float_compare(move_qty[move.id], 0, precision_rounding=move.product_id.uom_id.rounding) + if move_qty_cmp > 0: # (=In case no pack operations in picking) + main_domain = [('qty', '>', 0)] + preferred_domain = [('reservation_id', '=', move.id)] + fallback_domain = [('reservation_id', '=', False)] + fallback_domain2 = ['&', ('reservation_id', '!=', move.id), ('reservation_id', '!=', False)] + preferred_domain_list = [preferred_domain] + [fallback_domain] + [fallback_domain2] + self.check_tracking(cr, uid, [move.id], False, context=context) + qty = move_qty[move.id] + quants = quant_obj.quants_get_preferred_domain(cr, uid, qty, move, domain=main_domain, preferred_domain_list=preferred_domain_list, context=context) + quant_obj.quants_move(cr, uid, quants, move, move.location_dest_id, lot_id=move.restrict_lot_id.id, owner_id=move.restrict_partner_id.id, context=context) + + # 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: + procurement_ids.add(move.procurement_id.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._check_package_from_moves(cr, uid, ids, context=context) + #set the move as done + self.write(cr, uid, ids, {'state': 'done', 'date': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)}, context=context) + self.pool.get('procurement.order').check(cr, uid, list(procurement_ids), context=context) + #assign destination moves + if move_dest_ids: + self.action_assign(cr, uid, list(move_dest_ids), context=context) + #check picking state to set the date_done is needed + done_picking = [] + for picking in picking_obj.browse(cr, uid, list(pickings), context=context): + if picking.state == 'done' and not picking.date_done: + done_picking.append(picking.id) + if done_picking: + picking_obj.write(cr, uid, done_picking, {'date_done': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)}, context=context) + return True + + def unlink(self, cr, uid, ids, context=None): + context = context or {} + for move in self.browse(cr, uid, ids, context=context): + if move.state not in ('draft', 'cancel'): + raise UserError(_('You can only delete draft moves.')) + return super(stock_move, self).unlink(cr, uid, ids, context=context) + + def action_scrap(self, cr, uid, ids, quantity, location_id, restrict_lot_id=False, restrict_partner_id=False, context=None): + """ Move the scrap/damaged product into scrap location + @param cr: the database cursor + @param uid: the user id + @param ids: ids of stock move object to be scrapped + @param quantity : specify scrap qty + @param location_id : specify scrap location + @param context: context arguments + @return: Scraped lines + """ + quant_obj = self.pool.get("stock.quant") + #quantity should be given in MOVE UOM + if quantity <= 0: + raise UserError(_('Please provide a positive quantity to scrap.')) + res = [] + for move in self.browse(cr, uid, ids, context=context): + source_location = move.location_id + if move.state == 'done': + source_location = move.location_dest_id + #Previously used to prevent scraping from virtual location but not necessary anymore + #if source_location.usage != 'internal': + #restrict to scrap from a virtual location because it's meaningless and it may introduce errors in stock ('creating' new products from nowhere) + #raise UserError(_('Forbidden operation: it is not allowed to scrap products from a virtual location.')) + move_qty = move.product_qty + default_val = { + 'location_id': source_location.id, + 'product_uom_qty': quantity, + 'state': move.state, + 'scrapped': True, + 'location_dest_id': location_id, + 'restrict_lot_id': restrict_lot_id, + 'restrict_partner_id': restrict_partner_id, + } + new_move = self.copy(cr, uid, move.id, default_val) + + res += [new_move] + product_obj = self.pool.get('product.product') + for product in product_obj.browse(cr, uid, [move.product_id.id], context=context): + if move.picking_id: + uom = product.uom_id.name if product.uom_id else '' + message = _("%s %s %s has been moved to scrap.") % (quantity, uom, product.name) + move.picking_id.message_post(body=message) + + # We "flag" the quant from which we want to scrap the products. To do so: + # - we select the quants related to the move we scrap from + # - we reserve the quants with the scrapped move + # See self.action_done, et particularly how is defined the "preferred_domain" for clarification + scrap_move = self.browse(cr, uid, new_move, context=context) + if move.state == 'done' and scrap_move.location_id.usage not in ('supplier', 'inventory', 'production'): + domain = [('qty', '>', 0), ('history_ids', 'in', [move.id])] + # We use scrap_move data since a reservation makes sense for a move not already done + quants = quant_obj.quants_get_preferred_domain(cr, uid, quantity, scrap_move, domain=domain, context=context) + quant_obj.quants_reserve(cr, uid, quants, scrap_move, context=context) + self.action_done(cr, uid, res, context=context) + return res + + def split(self, cr, uid, ids, qty, restrict_lot_id=False, restrict_partner_id=False, context=None): + """ Splits qty from move move into a new move + :param move: browse record + :param qty: float. quantity to split (given in product UoM) + :param restrict_lot_id: optional production lot that can be given in order to force the new move to restrict its choice of quants to this lot. + :param restrict_partner_id: optional partner that can be given in order to force the new move to restrict its choice of quants to the ones belonging to this partner. + :param context: dictionay. can contains the special key 'source_location_id' in order to force the source location when copying the move + + returns the ID of the backorder move created + """ + move = self.browse(cr, uid, ids[0], context=context) + if move.state in ('done', 'cancel'): + raise UserError(_('You cannot split a move done')) + if move.state == 'draft': + #we restrict the split of a draft move because if not confirmed yet, it may be replaced by several other moves in + #case of phantom bom (with mrp module). And we don't want to deal with this complexity by copying the product that will explode. + raise UserError(_('You cannot split a draft move. It needs to be confirmed first.')) + + if move.product_qty <= qty or qty == 0: + return move.id + + uom_obj = self.pool.get('product.uom') + context = context or {} + + #HALF-UP rounding as only rounding errors will be because of propagation of error from default UoM + uom_qty = uom_obj._compute_qty_obj(cr, uid, move.product_id.uom_id, qty, move.product_uom, rounding_method='HALF-UP', context=context) + defaults = { + 'product_uom_qty': uom_qty, + 'procure_method': 'make_to_stock', + 'restrict_lot_id': restrict_lot_id, + 'split_from': move.id, + 'procurement_id': move.procurement_id.id, + 'move_dest_id': move.move_dest_id.id, + 'origin_returned_move_id': move.origin_returned_move_id.id, + } + + if restrict_partner_id: + defaults['restrict_partner_id'] = restrict_partner_id + + if context.get('source_location_id'): + defaults['location_id'] = context['source_location_id'] + new_move = self.copy(cr, uid, move.id, defaults, context=context) + + ctx = context.copy() + ctx['do_not_propagate'] = True + self.write(cr, uid, [move.id], { + 'product_uom_qty': move.product_uom_qty - uom_qty, + }, context=ctx) + + if move.move_dest_id and move.propagate and move.move_dest_id.state not in ('done', 'cancel'): + new_move_prop = self.split(cr, uid, [move.move_dest_id.id], qty, context=context) + self.write(cr, uid, [new_move], {'move_dest_id': new_move_prop}, context=context) + #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) + return self.action_confirm(cr, uid, [new_move], context=context)[0] + + def action_show_picking(self, cr, uid, ids, context=None): + assert len(ids) > 0 + picking_id = self.browse(cr, uid, ids[0], context=context).picking_id.id + if picking_id: + data_obj = self.pool['ir.model.data'] + view = data_obj.xmlid_to_res_id(cr, uid, 'stock.view_picking_form') + return { + 'name': _('Transfer'), + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'stock.picking', + 'views': [(view, 'form')], + 'view_id': view, + 'target': 'new', + 'res_id': picking_id, + } + show_picking = action_show_picking + + # Quants management + # ---------------------------------------------------------------------- + + def quants_unreserve(self, cr, uid, ids, context=None): + for move in self.browse(cr, uid, ids, context=context): + related_quants = [x.id for x in move.reserved_quant_ids] + if related_quants: + #if move has a picking_id, write on that picking that pack_operation might have changed and need to be recomputed + if move.partially_available: + move.write({'partially_available': False}) + self.pool['stock.quant'].write(cr, SUPERUSER_ID, related_quants, {'reservation_id': False}, context=context) diff --git a/addons/stock/stock.py b/addons/stock/stock.py index 080475648c0..85ff8c00446 100644 --- a/addons/stock/stock.py +++ b/addons/stock/stock.py @@ -282,1128 +282,6 @@ class stock_production_lot(osv.osv): } return False - -# ---------------------------------------------------- -# Move -# ---------------------------------------------------- - -class stock_move(osv.osv): - _name = "stock.move" - _description = "Stock Move" - _order = 'picking_id, sequence, id' - - def get_price_unit(self, cr, uid, ids, context=None): - """ Returns the unit price to store on the quant """ - move = self.browse(cr, uid, ids[0], context=context) - return move.price_unit or move.product_id.standard_price - - def name_get(self, cr, uid, ids, context=None): - res = [] - for line in self.browse(cr, uid, ids, context=context): - name = line.location_id.name + ' > ' + line.location_dest_id.name - if line.product_id.code: - name = line.product_id.code + ': ' + name - if line.picking_id.origin: - name = line.picking_id.origin + '/ ' + name - res.append((line.id, name)) - return res - - def _quantity_normalize(self, cr, uid, ids, name, args, context=None): - uom_obj = self.pool.get('product.uom') - res = {} - for m in self.browse(cr, uid, ids, context=context): - res[m.id] = uom_obj._compute_qty_obj(cr, uid, m.product_uom, m.product_uom_qty, m.product_id.uom_id, context=context) - return res - - def _get_remaining_qty(self, cr, uid, ids, field_name, args, context=None): - uom_obj = self.pool.get('product.uom') - res = {} - for move in self.browse(cr, uid, ids, context=context): - qty = move.product_qty - for record in move.linked_move_operation_ids: - qty -= record.qty - # Keeping in product default UoM - res[move.id] = float_round(qty, precision_rounding=move.product_id.uom_id.rounding) - return res - - def _get_lot_ids(self, cr, uid, ids, field_name, args, context=None): - res = dict.fromkeys(ids, False) - for move in self.browse(cr, uid, ids, context=context): - if move.state == 'done': - res[move.id] = [q.lot_id.id for q in move.quant_ids if q.lot_id] - else: - res[move.id] = [q.lot_id.id for q in move.reserved_quant_ids if q.lot_id] - return res - - def _get_product_availability(self, cr, uid, ids, field_name, args, context=None): - quant_obj = self.pool.get('stock.quant') - res = dict.fromkeys(ids, False) - for move in self.browse(cr, uid, ids, context=context): - if move.state == 'done': - res[move.id] = move.product_qty - else: - sublocation_ids = self.pool.get('stock.location').search(cr, uid, [('id', 'child_of', [move.location_id.id])], context=context) - quant_ids = quant_obj.search(cr, uid, [('location_id', 'in', sublocation_ids), ('product_id', '=', move.product_id.id), ('reservation_id', '=', False)], context=context) - availability = 0 - for quant in quant_obj.browse(cr, uid, quant_ids, context=context): - availability += quant.qty - res[move.id] = min(move.product_qty, availability) - return res - - def _get_string_qty_information(self, cr, uid, ids, field_name, args, context=None): - settings_obj = self.pool.get('stock.config.settings') - uom_obj = self.pool.get('product.uom') - res = dict.fromkeys(ids, '') - precision = self.pool['decimal.precision'].precision_get(cr, uid, 'Product Unit of Measure') - for move in self.browse(cr, uid, ids, context=context): - if move.state in ('draft', 'done', 'cancel') or move.location_id.usage != 'internal': - res[move.id] = '' # 'not applicable' or 'n/a' could work too - continue - total_available = min(move.product_qty, move.reserved_availability + move.availability) - total_available = uom_obj._compute_qty_obj(cr, uid, move.product_id.uom_id, total_available, move.product_uom, round=False, context=context) - total_available = float_round(total_available, precision_digits=precision) - info = str(total_available) - #look in the settings if we need to display the UoM name or not - config_ids = settings_obj.search(cr, uid, [], limit=1, order='id DESC', context=context) - if config_ids: - stock_settings = settings_obj.browse(cr, uid, config_ids[0], context=context) - if stock_settings.group_uom: - info += ' ' + move.product_uom.name - if move.reserved_availability: - if move.reserved_availability != total_available: - #some of the available quantity is assigned and some are available but not reserved - reserved_available = uom_obj._compute_qty_obj(cr, uid, move.product_id.uom_id, move.reserved_availability, move.product_uom, round=False, context=context) - reserved_available = float_round(reserved_available, precision_digits=precision) - info += _(' (%s reserved)') % str(reserved_available) - else: - #all available quantity is assigned - info += _(' (reserved)') - res[move.id] = info - return res - - def _get_reserved_availability(self, cr, uid, ids, field_name, args, context=None): - res = dict.fromkeys(ids, 0) - for move in self.browse(cr, uid, ids, context=context): - res[move.id] = sum([quant.qty for quant in move.reserved_quant_ids]) - return res - - def _set_product_qty(self, cr, uid, id, field, value, arg, context=None): - """ 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`.')) - - _columns = { - 'sequence': fields.integer('Sequence'), - 'name': fields.char('Description', required=True, select=True), - 'priority': fields.selection(procurement.PROCUREMENT_PRIORITIES, 'Priority'), - 'create_date': fields.datetime('Creation Date', readonly=True, select=True), - 'date': fields.datetime('Date', required=True, select=True, help="Move date: scheduled date until move is done, then date of actual move processing", states={'done': [('readonly', True)]}), - 'date_expected': fields.datetime('Expected Date', states={'done': [('readonly', True)]}, required=True, select=True, help="Scheduled date for the processing of this move"), - 'product_id': fields.many2one('product.product', 'Product', required=True, select=True, domain=[('type', 'in', ['product', 'consu'])], states={'done': [('readonly', True)]}), - 'product_qty': fields.function(_quantity_normalize, fnct_inv=_set_product_qty, type='float', digits=0, store={ - _name: (lambda self, cr, uid, ids, c={}: ids, ['product_id', 'product_uom', 'product_uom_qty'], 10), - }, string='Quantity', - help='Quantity in the default UoM of the product'), - 'product_uom_qty': fields.float('Quantity', digits_compute=dp.get_precision('Product Unit of Measure'), - required=True, states={'done': [('readonly', True)]}, - help="This is the quantity of products from an inventory " - "point of view. For moves in the state 'done', this is the " - "quantity of products that were actually moved. For other " - "moves, this is the quantity of product that is planned to " - "be moved. Lowering this quantity does not generate a " - "backorder. Changing this quantity on assigned moves affects " - "the product reservation, and should be done with care." - ), - 'product_uom': fields.many2one('product.uom', 'Unit of Measure', required=True, states={'done': [('readonly', True)]}), - 'product_tmpl_id': fields.related('product_id', 'product_tmpl_id', type='many2one', relation='product.template', string='Product Template'), - - 'product_packaging': fields.many2one('product.packaging', 'preferred Packaging', help="It specifies attributes of packaging like type, quantity of packaging,etc."), - - 'location_id': fields.many2one('stock.location', 'Source Location', required=True, select=True, auto_join=True, - states={'done': [('readonly', True)]}, help="Sets a location if you produce at a fixed location. This can be a partner location if you subcontract the manufacturing operations."), - 'location_dest_id': fields.many2one('stock.location', 'Destination Location', required=True, states={'done': [('readonly', True)]}, select=True, - auto_join=True, help="Location where the system will stock the finished products."), - - 'partner_id': fields.many2one('res.partner', 'Destination Address ', states={'done': [('readonly', True)]}, help="Optional address where goods are to be delivered, specifically used for allotment"), - 'picking_partner_id': fields.related('picking_id', 'partner_id', type='many2one', relation='res.partner', string='Transfer Destination Address'), - - 'move_dest_id': fields.many2one('stock.move', 'Destination Move', help="Optional: next stock move when chaining them", select=True, copy=False), - 'move_orig_ids': fields.one2many('stock.move', 'move_dest_id', 'Original Move', help="Optional: previous stock move when chaining them", select=True), - - 'picking_id': fields.many2one('stock.picking', 'Transfer Reference', select=True, states={'done': [('readonly', True)]}), - 'note': fields.text('Notes'), - 'state': fields.selection([('draft', 'New'), - ('cancel', 'Cancelled'), - ('waiting', 'Waiting Another Move'), - ('confirmed', 'Waiting Availability'), - ('assigned', 'Available'), - ('done', 'Done'), - ], 'Status', readonly=True, select=True, copy=False, - 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', readonly=True, help="Checks if the move has some stock reserved", copy=False), - '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 - - 'company_id': fields.many2one('res.company', 'Company', required=True, select=True), - 'split_from': fields.many2one('stock.move', string="Move Split From", help="Technical field used to track the origin of a split move, which can be useful in case of debug", copy=False), - 'backorder_id': fields.related('picking_id', 'backorder_id', type='many2one', relation="stock.picking", string="Back Order of", select=True), - 'origin': fields.char("Source Document"), - 'procure_method': fields.selection([('make_to_stock', 'Default: Take From Stock'), ('make_to_order', 'Advanced: Apply Procurement Rules')], 'Supply Method', required=True, - help="""By default, the system will take from the stock in the source location and passively wait for availability. The other possibility allows you to directly create a procurement on the source location (and thus ignore 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."""), - - # used for colors in tree views: - 'scrapped': fields.related('location_dest_id', 'scrap_location', type='boolean', relation='stock.location', string='Scrapped', readonly=True), - - 'quant_ids': fields.many2many('stock.quant', 'stock_quant_move_rel', 'move_id', 'quant_id', 'Moved Quants', copy=False), - 'reserved_quant_ids': fields.one2many('stock.quant', 'reservation_id', 'Reserved quants'), - 'linked_move_operation_ids': fields.one2many('stock.move.operation.link', 'move_id', string='Linked Operations', readonly=True, help='Operations that impact this move for the computation of the remaining quantities'), - 'remaining_qty': fields.function(_get_remaining_qty, type='float', string='Remaining Quantity', digits=0, - states={'done': [('readonly', True)]}, help="Remaining Quantity in default UoM according to operations matched with this move"), - 'procurement_id': fields.many2one('procurement.order', 'Procurement'), - 'group_id': fields.many2one('procurement.group', 'Procurement Group'), - 'rule_id': fields.many2one('procurement.rule', 'Procurement Rule', help='The procurement rule that created this stock move'), - 'push_rule_id': fields.many2one('stock.location.path', 'Push Rule', help='The push rule that created this stock move'), - 'propagate': fields.boolean('Propagate cancel and split', help='If checked, when this move is cancelled, cancel the linked move too'), - 'picking_type_id': fields.many2one('stock.picking.type', 'Picking Type'), - 'inventory_id': fields.many2one('stock.inventory', 'Inventory'), - 'lot_ids': fields.function(_get_lot_ids, type='many2many', relation='stock.production.lot', string='Lots'), - 'origin_returned_move_id': fields.many2one('stock.move', 'Origin return move', help='move that created the return move', copy=False), - '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.function(_get_reserved_availability, type='float', string='Quantity Reserved', readonly=True, help='Quantity that has already been reserved for this move'), - 'availability': fields.function(_get_product_availability, type='float', string='Forecasted Quantity', readonly=True, help='Quantity in stock that can still be reserved for this move'), - 'string_availability_info': fields.function(_get_string_qty_information, type='text', string='Availability', readonly=True, help='Show various information on stock availability for this move'), - 'restrict_lot_id': fields.many2one('stock.production.lot', 'Lot', help="Technical field used to depict a restriction on the lot of quants to consider when marking this move as 'done'"), - '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)."), - 'ordered_qty': fields.float('Ordered Quantity', digits_compute=dp.get_precision('Product Unit of Measure')), - } - - def _default_destination_address(self, cr, uid, context=None): - return False - - def _default_group_id(self, cr, uid, context=None): - context = context or {} - if context.get('default_picking_id', False): - picking = self.pool.get('stock.picking').browse(cr, uid, context['default_picking_id'], context=context) - return picking.group_id.id - return False - - _defaults = { - 'partner_id': _default_destination_address, - 'state': 'draft', - 'priority': '1', - 'product_uom_qty': 1.0, - 'sequence': 10, - 'scrapped': False, - 'date': fields.datetime.now, - 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'stock.move', context=c), - 'date_expected': fields.datetime.now, - 'procure_method': 'make_to_stock', - 'propagate': True, - 'partially_available': False, - 'group_id': _default_group_id, - } - - def _check_uom(self, cr, uid, ids, context=None): - for move in self.browse(cr, uid, ids, context=context): - if move.product_id.uom_id.category_id.id != move.product_uom.category_id.id: - return False - return True - - _constraints = [ - (_check_uom, - 'You try to move a product using a UoM that is not compatible with the UoM of the product moved. Please use an UoM in the same UoM category.', - ['product_uom']), - ] - def init(self, cr): - cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('stock_move_product_location_index',)) - if not cr.fetchone(): - cr.execute('CREATE INDEX stock_move_product_location_index ON stock_move (product_id, location_id, location_dest_id, company_id, state)') - - def get_removal_strategy(self, cr, uid, ids, context=None): - ''' Returns the removal strategy to consider for the given move/ops - :rtype: char - ''' - move = self.browse(cr, uid, ids[0], context=context) - product = move.product_id - location = move.location_id - if product.categ_id.removal_strategy_id: - return product.categ_id.removal_strategy_id.method - loc = location - while loc: - if loc.removal_strategy_id: - return loc.removal_strategy_id.method - loc = loc.location_id - return 'fifo' - - @api.cr_uid_ids_context - def do_unreserve(self, cr, uid, move_ids, context=None): - for move in self.browse(cr, uid, move_ids, context=context): - if move.state in ('done', 'cancel'): - raise UserError(_('Cannot unreserve a done move')) - move.quants_unreserve() - if self.find_move_ancestors(cr, uid, [move.id], context=context): - self.write(cr, uid, [move.id], {'state': 'waiting'}, context=context) - else: - self.write(cr, uid, [move.id], {'state': 'confirmed'}, context=context) - - def _push_apply(self, cr, uid, ids, context=None): - push_obj = self.pool.get("stock.location.path") - for move in self.browse(cr, uid, ids, context=context): - #1) if the move is already chained, there is no need to check push rules - #2) 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) - if not move.move_dest_id: - domain = [('location_from_id', '=', move.location_dest_id.id)] - #priority goes to the route defined on the product and product category - route_ids = [x.id for x in move.product_id.route_ids + move.product_id.categ_id.total_route_ids] - rules = push_obj.search(cr, uid, domain + [('route_id', 'in', route_ids)], order='route_sequence, sequence', context=context) - if not rules: - #then we search on the warehouse if a rule can apply - wh_route_ids = [] - if move.warehouse_id: - wh_route_ids = [x.id for x in move.warehouse_id.route_ids] - elif move.picking_id.picking_type_id.warehouse_id: - wh_route_ids = [x.id for x in move.picking_id.picking_type_id.warehouse_id.route_ids] - if wh_route_ids: - rules = push_obj.search(cr, uid, domain + [('route_id', 'in', wh_route_ids)], order='route_sequence, sequence', context=context) - if not rules: - #if no specialized push rule has been found yet, we try to find a general one (without route) - rules = push_obj.search(cr, uid, domain + [('route_id', '=', False)], order='sequence', context=context) - if rules: - rule = push_obj.browse(cr, uid, rules[0], context=context) - # Make sure it is not returning the return - if (not move.origin_returned_move_id or move.origin_returned_move_id.location_id.id != rule.location_dest_id.id): - push_obj._apply(cr, uid, [rule.id], move, context=context) - return True - - def _prepare_procurement_from_move(self, cr, uid, ids, context=None): - move = self.browse(cr, uid, ids, context=context)[0] - origin = (move.group_id and (move.group_id.name + ":") or "") + (move.rule_id and move.rule_id.name or move.origin or move.picking_id.name or "/") - group_id = move.group_id and move.group_id.id or False - if move.rule_id: - if move.rule_id.group_propagation_option == 'fixed' and move.rule_id.group_id: - group_id = move.rule_id.group_id.id - elif move.rule_id.group_propagation_option == 'none': - group_id = False - return { - 'name': move.rule_id and move.rule_id.name or "/", - 'origin': origin, - 'company_id': move.company_id and move.company_id.id or False, - 'date_planned': move.date, - 'product_id': move.product_id.id, - 'product_qty': move.product_uom_qty, - 'product_uom': move.product_uom.id, - 'location_id': move.location_id.id, - 'move_dest_id': move.id, - 'group_id': group_id, - 'route_ids': [(4, x.id) for x in move.route_ids], - 'warehouse_id': move.warehouse_id.id or (move.picking_type_id and move.picking_type_id.warehouse_id.id or False), - 'priority': move.priority, - } - - def _create_procurements(self, cr, uid, ids, context=None): - res = [] - for move in self.browse(cr, uid, ids, context=context): - res.append(self.pool.get("procurement.order").create(cr, uid, move._prepare_procurement_from_move(), context=context)) - # Run procurements immediately when generated from multiple moves - self.pool['procurement.order'].run(cr, uid, res, context=context) - return res - - def create(self, cr, uid, vals, context=None): - if context is None: - context = {} - picking_obj = self.pool['stock.picking'] - track = not context.get('mail_notrack') and vals.get('picking_id') - if track: - picking = picking_obj.browse(cr, uid, vals['picking_id'], context=context) - initial_values = {picking.id: {'state': picking.state}} - vals['ordered_qty'] = vals.get('product_uom_qty') - res = super(stock_move, self).create(cr, uid, vals, context=context) - if track: - picking_obj.message_track(cr, uid, [vals['picking_id']], picking_obj.fields_get(cr, uid, ['state'], context=context), initial_values, context=context) - return res - - def write(self, cr, uid, ids, vals, context=None): - if context is None: - context = {} - if isinstance(ids, (int, long)): - ids = [ids] - picking_obj = self.pool['stock.picking'] - # Check that we do not modify a stock.move which is done - frozen_fields = set(['product_qty', 'product_uom', 'location_id', 'location_dest_id', 'product_id']) - moves = self.browse(cr, uid, ids, context=context) - for move in moves: - if move.state == 'done': - if frozen_fields.intersection(vals): - raise UserError(_('Quantities, Units of Measure, Products and Locations cannot be modified on stock moves that have already been processed (except by the Administrator).')) - propagated_changes_dict = {} - #propagation of quantity change - if vals.get('product_uom_qty'): - propagated_changes_dict['product_uom_qty'] = vals['product_uom_qty'] - if vals.get('product_uom_id'): - propagated_changes_dict['product_uom_id'] = vals['product_uom_id'] - if vals.get('product_uos_qty'): - propagated_changes_dict['product_uos_qty'] = vals['product_uos_qty'] - if vals.get('product_uos_id'): - propagated_changes_dict['product_uos_id'] = vals['product_uos_id'] - #propagation of expected date: - propagated_date_field = False - if vals.get('date_expected'): - #propagate any manual change of the expected date - propagated_date_field = 'date_expected' - elif (vals.get('state', '') == 'done' and vals.get('date')): - #propagate also any delta observed when setting the move as done - propagated_date_field = 'date' - - if not context.get('do_not_propagate', False) and (propagated_date_field or propagated_changes_dict): - #any propagation is (maybe) needed - for move in self.browse(cr, uid, ids, context=context): - if move.move_dest_id and move.propagate: - if 'date_expected' in propagated_changes_dict: - propagated_changes_dict.pop('date_expected') - if propagated_date_field: - current_date = datetime.strptime(move.date_expected, DEFAULT_SERVER_DATETIME_FORMAT) - new_date = datetime.strptime(vals.get(propagated_date_field), DEFAULT_SERVER_DATETIME_FORMAT) - delta = new_date - current_date - if abs(delta.days) >= move.company_id.propagation_minimum_delta: - old_move_date = datetime.strptime(move.move_dest_id.date_expected, DEFAULT_SERVER_DATETIME_FORMAT) - new_move_date = (old_move_date + relativedelta.relativedelta(days=delta.days or 0)).strftime(DEFAULT_SERVER_DATETIME_FORMAT) - propagated_changes_dict['date_expected'] = new_move_date - #For pushed moves as well as for pulled moves, propagate by recursive call of write(). - #Note that, for pulled moves we intentionally don't propagate on the procurement. - if propagated_changes_dict: - self.write(cr, uid, [move.move_dest_id.id], propagated_changes_dict, context=context) - track_pickings = not context.get('mail_notrack') and any(field in vals for field in ['state', 'picking_id', 'partially_available']) - if track_pickings: - to_track_picking_ids = set([move.picking_id.id for move in moves if move.picking_id]) - if vals.get('picking_id'): - to_track_picking_ids.add(vals['picking_id']) - to_track_picking_ids = list(to_track_picking_ids) - pickings = picking_obj.browse(cr, uid, to_track_picking_ids, context=context) - initial_values = dict((picking.id, {'state': picking.state}) for picking in pickings) - res = super(stock_move, self).write(cr, uid, ids, vals, context=context) - if track_pickings: - picking_obj.message_track(cr, uid, to_track_picking_ids, picking_obj.fields_get(cr, uid, ['state'], context=context), initial_values, context=context) - return res - - def onchange_quantity(self, cr, uid, ids, product_id, product_qty, product_uom): - """ On change of product quantity finds UoM - @param product_id: Product id - @param product_qty: Changed Quantity of product - @param product_uom: Unit of measure of product - @return: Dictionary of values - """ - warning = {} - result = {} - - if (not product_id) or (product_qty <= 0.0): - result['product_qty'] = 0.0 - return {'value': result} - - product_obj = self.pool.get('product.product') - # Warn if the quantity was decreased - if ids: - for move in self.read(cr, uid, ids, ['product_qty']): - if product_qty < move['product_qty']: - warning.update({ - 'title': _('Information'), - 'message': _("By changing this quantity here, you accept the " - "new quantity as complete: Odoo will not " - "automatically generate a back order.")}) - break - return {'warning': warning} - - def onchange_product_id(self, cr, uid, ids, prod_id=False, loc_id=False, loc_dest_id=False, partner_id=False): - """ On change of product id, if finds UoM, quantity - @param prod_id: Changed Product id - @param loc_id: Source location id - @param loc_dest_id: Destination location id - @param partner_id: Address id of partner - @return: Dictionary of values - """ - if not prod_id: - return {'domain': {'product_uom': []}} - user = self.pool.get('res.users').browse(cr, uid, uid) - lang = user and user.lang or False - if partner_id: - addr_rec = self.pool.get('res.partner').browse(cr, uid, partner_id) - if addr_rec: - lang = addr_rec and addr_rec.lang or False - ctx = {'lang': lang} - - product = self.pool.get('product.product').browse(cr, uid, [prod_id], context=ctx)[0] - result = { - 'name': product.partner_ref, - 'product_uom': product.uom_id.id, - 'product_uom_qty': 1.00, - } - if loc_id: - result['location_id'] = loc_id - if loc_dest_id: - result['location_dest_id'] = loc_dest_id - res = {'value': result, - 'domain': {'product_uom': [('category_id', '=', product.uom_id.category_id.id)]} - } - return res - - def _prepare_picking_assign(self, cr, uid, ids, context=None): - """ Prepares a new picking for this move as it could not be assigned to - another picking. This method is designed to be inherited. - """ - move = self.browse(cr, uid, ids, context=context)[0] - values = { - 'origin': move.origin, - 'company_id': move.company_id and move.company_id.id or False, - 'move_type': move.group_id and move.group_id.move_type or 'direct', - 'partner_id': move.partner_id.id or False, - 'picking_type_id': move.picking_type_id and move.picking_type_id.id or False, - 'location_id': move.location_id.id, - 'location_dest_id': move.location_dest_id.id, - } - return values - - @api.cr_uid_ids_context - def _picking_assign(self, cr, uid, move_ids, context=None): - """Try to assign the moves to an existing picking - that has not been reserved yet and has the same - procurement group, locations and picking type (moves should already have them identical) - Otherwise, create a new picking to assign them to. - """ - move = self.browse(cr, uid, move_ids, context=context)[0] - pick_obj = self.pool.get("stock.picking") - picks = pick_obj.search(cr, uid, [ - ('group_id', '=', move.group_id.id), - ('location_id', '=', move.location_id.id), - ('location_dest_id', '=', move.location_dest_id.id), - ('picking_type_id', '=', move.picking_type_id.id), - ('printed', '=', False), - ('state', 'in', ['draft', 'confirmed', 'waiting', 'partially_available', 'assigned'])], limit=1, context=context) - if picks: - pick = picks[0] - else: - values = move._prepare_picking_assign() - pick = pick_obj.create(cr, uid, values, context=context) - return self.write(cr, uid, move_ids, {'picking_id': pick}, context=context) - - def onchange_date(self, cr, uid, ids, date, date_expected, context=None): - """ On change of Scheduled Date gives a Move date. - @param date_expected: Scheduled Date - @param date: Move Date - @return: Move Date - """ - if not date_expected: - date_expected = time.strftime(DEFAULT_SERVER_DATETIME_FORMAT) - return {'value': {'date': date_expected}} - - def attribute_price(self, cr, uid, ids, context=None): - """ - Attribute price to move, important in inter-company moves or receipts with only one partner - """ - move = self.browse(cr, uid, ids[0], context=context) - if not move.price_unit: - price = move.product_id.standard_price - self.write(cr, uid, [move.id], {'price_unit': price}) - - def action_confirm(self, cr, uid, ids, context=None): - """ Confirms stock move or put it in waiting if it's linked to another move. - @return: List of ids. - """ - if not context: - context = {} - if isinstance(ids, (int, long)): - ids = [ids] - states = { - 'confirmed': [], - 'waiting': [] - } - to_assign = {} - for move in self.browse(cr, uid, ids, context=context): - self.attribute_price(cr, uid, [move.id], context=context) - state = 'confirmed' - #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: - state = 'waiting' - #if the move is split and some of the ancestor was preceeded, then it's waiting as well - elif move.split_from: - move2 = move.split_from - while move2 and state != 'waiting': - if move2.move_orig_ids: - state = 'waiting' - move2 = move2.split_from - states[state].append(move.id) - - 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: - to_assign[key] = [] - to_assign[key].append(move.id) - moves = [move for move in self.browse(cr, uid, states['confirmed'], context=context) if move.procure_method == 'make_to_order'] - self._create_procurements(cr, uid, [move.id for move in moves], context=context) - for move in moves: - states['waiting'].append(move.id) - states['confirmed'].remove(move.id) - - for state, write_ids in states.items(): - if len(write_ids): - self.write(cr, uid, write_ids, {'state': state}, context=context) - #assign picking in batch for all confirmed move that share the same details - for key, move_ids in to_assign.items(): - self._picking_assign(cr, uid, move_ids, context=context) - moves = self.browse(cr, uid, ids, context=context) - moves._push_apply() - return ids - - def force_assign(self, cr, uid, ids, context=None): - """ Changes the state to assigned. - @return: True - """ - res = self.write(cr, uid, ids, {'state': 'assigned'}, context=context) - self.check_recompute_pack_op(cr, uid, ids, context=context) - return res - - def check_tracking(self, cr, uid, ids, ops, context=None): - """ Checks if serial number is assigned to stock move or not and raise an error if it had to. - """ - move = self.browse(cr, uid, ids[0], context=context) - if move.picking_id and (move.picking_id.picking_type_id.use_existing_lots or move.picking_id.picking_type_id.use_create_lots) and \ - move.product_id.tracking != 'none': - if not (move.restrict_lot_id or (ops and (ops.product_id and ops.pack_lot_ids)) or (ops and not ops.product_id)): - raise UserError(_('You need to provide a Lot/Serial Number for product %s') % move.product_id.name) - - def check_recompute_pack_op(self, cr, uid, ids, context=None): - pickings = list(set([x.picking_id for x in self.browse(cr, uid, ids, context=context) if x.picking_id])) - pickings_partial = [] - pickings_write = [] - pick_obj = self.pool['stock.picking'] - for pick in pickings: - if pick.state in ('waiting', 'confirmed'): #In case of 'all at once' delivery method it should not prepare pack operations - continue - # Check if someone was treating the picking already - if not any([x.qty_done > 0 for x in pick.pack_operation_ids]): - pickings_partial.append(pick.id) - else: - pickings_write.append(pick.id) - if pickings_partial: - pick_obj.do_prepare_partial(cr, uid, pickings_partial, context=context) - if pickings_write: - pick_obj.write(cr, uid, pickings_write, {'recompute_pack_op': True}, context=context) - - def action_assign(self, cr, uid, ids, no_prepare=False, context=None): - """ Checks the product type and accordingly writes the state. - """ - context = context or {} - quant_obj = self.pool.get("stock.quant") - uom_obj = self.pool['product.uom'] - to_assign_moves = set() - main_domain = {} - todo_moves = [] - operations = set() - self.do_unreserve(cr, uid, [x.id for x in self.browse(cr, uid, ids, context=context) if x.reserved_quant_ids and x.state in ['confirmed', 'waiting', 'assigned']], context=context) - for move in self.browse(cr, uid, ids, context=context): - if move.state not in ('confirmed', 'waiting', 'assigned'): - continue - if move.location_id.usage in ('supplier', 'inventory', 'production'): - to_assign_moves.add(move.id) - #in case the move is returned, we want to try to find quants before forcing the assignment - if not move.origin_returned_move_id: - continue - if move.product_id.type == 'consu': - to_assign_moves.add(move.id) - continue - else: - todo_moves.append(move) - - #we always search for yet unassigned quants - main_domain[move.id] = [('reservation_id', '=', False), ('qty', '>', 0)] - - #if the move is preceeded, restrict the choice of quants in the ones moved previously in original move - ancestors = self.find_move_ancestors(cr, uid, [move.id], context=context) - if move.state == 'waiting' and not ancestors: - #if the waiting move hasn't yet any ancestor (PO/MO not confirmed yet), don't find any quant available in stock - main_domain[move.id] += [('id', '=', False)] - elif ancestors: - main_domain[move.id] += [('history_ids', 'in', ancestors)] - - #if the move is returned from another, restrict the choice of quants to the ones that follow the returned move - if move.origin_returned_move_id: - main_domain[move.id] += [('history_ids', 'in', move.origin_returned_move_id.id)] - for link in move.linked_move_operation_ids: - operations.add(link.operation_id) - # Check all ops and sort them: we want to process first the packages, then operations with lot then the rest - operations = list(operations) - operations.sort(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 ops in operations: - #first try to find quants based on specific domains given by linked operations for the case where we want to rereserve according to existing pack operations - if not (ops.product_id and ops.pack_lot_ids): - for record in ops.linked_move_operation_ids: - move = record.move_id - if move.id in main_domain: - qty = record.qty - domain = main_domain[move.id] - if qty: - quants = quant_obj.quants_get_preferred_domain(cr, uid, qty, move, ops=ops, domain=domain, preferred_domain_list=[], context=context) - quant_obj.quants_reserve(cr, uid, quants, move, record, context=context) - else: - lot_qty = {} - rounding = ops.product_id.uom_id.rounding - for pack_lot in ops.pack_lot_ids: - lot_qty[pack_lot.lot_id.id] = uom_obj._compute_qty(cr, uid, ops.product_uom_id.id, pack_lot.qty, ops.product_id.uom_id.id) - for record in ops.linked_move_operation_ids: - move_qty = record.qty - move = record.move_id - domain = main_domain[move.id] - for lot in lot_qty: - if float_compare(lot_qty[lot], 0, precision_rounding=rounding) > 0 and float_compare(move_qty, 0, precision_rounding=rounding) > 0: - qty = min(lot_qty[lot], move_qty) - quants = quant_obj.quants_get_preferred_domain(cr, uid, qty, move, ops=ops, lot_id=lot, domain=domain, preferred_domain_list=[], context=context) - quant_obj.quants_reserve(cr, uid, quants, move, record, context=context) - lot_qty[lot] -= qty - move_qty -= qty - - for move in todo_moves: - #then if the move isn't totally assigned, try to find quants without any specific domain - if move.state != 'assigned': - qty_already_assigned = move.reserved_availability - qty = move.product_qty - qty_already_assigned - quants = quant_obj.quants_get_preferred_domain(cr, uid, qty, move, domain=main_domain[move.id], preferred_domain_list=[], context=context) - quant_obj.quants_reserve(cr, uid, quants, move, context=context) - - #force assignation of consumable products and incoming from supplier/inventory/production - # Do not take force_assign as it would create pack operations - if to_assign_moves: - self.write(cr, uid, list(to_assign_moves), {'state': 'assigned'}, context=context) - if not no_prepare: - self.check_recompute_pack_op(cr, uid, ids, context=context) - - def action_cancel(self, cr, uid, ids, context=None): - """ Cancels the moves and if all moves are cancelled it cancels the picking. - @return: True - """ - procurement_obj = self.pool.get('procurement.order') - context = context or {} - procs_to_check = set() - for move in self.browse(cr, uid, ids, context=context): - if move.state == 'done': - raise UserError(_('You cannot cancel a stock move that has been set to \'Done\'.')) - if move.reserved_quant_ids: - move.quants_unreserve() - if context.get('cancel_procurement'): - if move.propagate: - procurement_ids = procurement_obj.search(cr, uid, [('move_dest_id', '=', move.id)], context=context) - procurement_obj.cancel(cr, uid, procurement_ids, context=context) - else: - if move.move_dest_id: - if move.propagate: - self.action_cancel(cr, uid, [move.move_dest_id.id], context=context) - elif move.move_dest_id.state == 'waiting': - #If waiting, the chain will be broken and we are not sure if we can still wait for it (=> could take from stock instead) - self.write(cr, uid, [move.move_dest_id.id], {'state': 'confirmed'}, context=context) - if move.procurement_id: - # Does the same as procurement check, only eliminating a refresh - procs_to_check.add(move.procurement_id.id) - - res = self.write(cr, uid, ids, {'state': 'cancel', 'move_dest_id': False}, context=context) - if procs_to_check: - procurement_obj.check(cr, uid, list(procs_to_check), context=context) - return res - - def _check_package_from_moves(self, cr, uid, ids, context=None): - pack_obj = self.pool.get("stock.quant.package") - packs = set() - for move in self.browse(cr, uid, ids, context=context): - packs |= set([q.package_id.id for q in move.quant_ids if q.package_id and q.qty > 0]) - return pack_obj._check_location_constraint(cr, uid, list(packs), context=context) - - def find_move_ancestors(self, cr, uid, ids, context=None): - '''Find the first level ancestors of given move ''' - move = self.browse(cr, uid, ids[0], context=context) - ancestors = [] - move2 = move - while move2: - ancestors += [x.id for x in move2.move_orig_ids] - #loop on the split_from to find the ancestor of split moves only if the move has not direct ancestor (priority goes to them) - move2 = not move2.move_orig_ids and move2.split_from or False - return ancestors - - @api.cr_uid_ids_context - def recalculate_move_state(self, cr, uid, move_ids, context=None): - '''Recompute the state of moves given because their reserved quants were used to fulfill another operation''' - for move in self.browse(cr, uid, move_ids, context=context): - vals = {} - reserved_quant_ids = move.reserved_quant_ids - if len(reserved_quant_ids) > 0 and not move.partially_available: - vals['partially_available'] = True - if len(reserved_quant_ids) == 0 and move.partially_available: - vals['partially_available'] = False - if move.state == 'assigned': - if self.find_move_ancestors(cr, uid, [move.id], context=context): - vals['state'] = 'waiting' - else: - vals['state'] = 'confirmed' - if vals: - self.write(cr, uid, [move.id], vals, context=context) - - def _move_quants_by_lot(self, cr, uid, ops, lot_qty, quants_taken, false_quants, lot_move_qty, quant_dest_package_id, context=None): - """ - This function is used to process all the pack operation lots of a pack operation - For every move: - First, we check the quants with lot already reserved (and those are already subtracted from the lots to do) - Then go through all the lots to process: - Add reserved false lots lot by lot - Check if there are not reserved quants or reserved elsewhere with that lot or without lot (with the traditional method) - """ - quant_obj = self.pool['stock.quant'] - fallback_domain = [('reservation_id', '=', False)] - fallback_domain2 = ['&', ('reservation_id', 'not in', [x for x in lot_move_qty.keys()]), ('reservation_id', '!=', False)] - preferred_domain_list = [fallback_domain] + [fallback_domain2] - rounding = ops.product_id.uom_id.rounding - for move in lot_move_qty: - move_quants_dict = {} - move_rec = self.pool['stock.move'].browse(cr, uid, move, context=context) - # Assign quants already reserved with lot to the correct - for quant in quants_taken: - move_quants_dict.setdefault(quant[0].lot_id.id, []) - move_quants_dict[quant[0].lot_id.id] += [quant] - false_quants_move = [x for x in false_quants if x[0].reservation_id.id == move] - for lot in lot_qty: - move_quants_dict.setdefault(lot, []) - redo_false_quants = False - # Take remaining reserved quants with no lot first - # (This will be used mainly when incoming had no lot and you do outgoing with) - while false_quants_move and float_compare(lot_qty[lot], 0, precision_rounding=rounding) > 0 and float_compare(lot_move_qty[move], 0, precision_rounding=rounding) > 0: - qty_min = min(lot_qty[lot], lot_move_qty[move]) - if false_quants_move[0].qty > qty_min: - move_quants_dict[lot] += [(false_quants_move[0], qty_min)] - qty = qty_min - redo_false_quants = True - else: - qty = false_quants_move[0].qty - move_quants_dict[lot] += [(false_quants_move[0], qty)] - false_quants_move.pop(0) - lot_qty[lot] -= qty - lot_move_qty[move] -= qty - - # Search other with first matching lots and then without lots - if float_compare(lot_move_qty[move], 0, precision_rounding=rounding) > 0 and float_compare(lot_qty[lot], 0, precision_rounding=rounding) > 0: - # Search if we can find quants with that lot - domain = [('qty', '>', 0)] - qty = min(lot_qty[lot], lot_move_qty[move]) - quants = quant_obj.quants_get_preferred_domain(cr, uid, qty, move_rec, ops=ops, lot_id=lot, domain=domain, - preferred_domain_list=preferred_domain_list, context=context) - move_quants_dict[lot] += quants - lot_qty[lot] -= qty - lot_move_qty[move] -= qty - - #Move all the quants related to that lot/move - if move_quants_dict[lot]: - quant_obj.quants_move(cr, uid, move_quants_dict[lot], move_rec, ops.location_dest_id, location_from=ops.location_id, - lot_id=lot, owner_id=ops.owner_id.id, src_package_id=ops.package_id.id, - dest_package_id=quant_dest_package_id, context=context) - if redo_false_quants: - move_rec = self.pool['stock.move'].browse(cr, uid, move, context=context) - false_quants_move = [x for x in move_rec.reserved_quant_ids if (not x.lot_id) and (x.owner_id.id == ops.owner_id.id) \ - and (x.location_id.id == ops.location_id.id) and (x.package_id.id != ops.package_id.id)] - - def action_done(self, cr, uid, ids, context=None): - """ Process completely the moves given as ids and if all moves are done, it will finish the picking. - """ - context = context or {} - picking_obj = self.pool.get("stock.picking") - quant_obj = self.pool.get("stock.quant") - uom_obj = self.pool.get("product.uom") - todo = [move.id for move in self.browse(cr, uid, ids, context=context) if move.state == "draft"] - if todo: - ids = self.action_confirm(cr, uid, todo, context=context) - pickings = set() - procurement_ids = set() - #Search operations that are linked to the moves - operations = set() - move_qty = {} - for move in self.browse(cr, uid, ids, context=context): - move_qty[move.id] = move.product_qty - for link in move.linked_move_operation_ids: - operations.add(link.operation_id) - - #Sort operations according to entire packages first, then package + lot, package only, lot only - operations = list(operations) - operations.sort(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 ops in operations: - if ops.picking_id: - pickings.add(ops.picking_id.id) - entire_pack=False - if ops.product_id: - #If a product is given, the result is always put immediately in the result package (if it is False, they are without package) - quant_dest_package_id = ops.result_package_id.id - else: - # When a pack is moved entirely, the quants should not be written anything for the destination package - quant_dest_package_id = False - entire_pack=True - lot_qty = {} - tot_qty = 0.0 - for pack_lot in ops.pack_lot_ids: - qty = uom_obj._compute_qty(cr, uid, ops.product_uom_id.id, pack_lot.qty, ops.product_id.uom_id.id) - lot_qty[pack_lot.lot_id.id] = qty - tot_qty += pack_lot.qty - if ops.pack_lot_ids and ops.product_id and float_compare(tot_qty, ops.product_qty, precision_rounding=ops.product_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 = {} - #Group links by move first - move_qty_ops = {} - for record in ops.linked_move_operation_ids: - move = record.move_id - if not move_qty_ops.get(move): - move_qty_ops[move] = record.qty - else: - move_qty_ops[move] += record.qty - #Process every move only once for every pack operation - for move in move_qty_ops: - main_domain = [('qty', '>', 0)] - self.check_tracking(cr, uid, [move.id], ops, context=context) - preferred_domain = [('reservation_id', '=', move.id)] - fallback_domain = [('reservation_id', '=', False)] - fallback_domain2 = ['&', ('reservation_id', '!=', move.id), ('reservation_id', '!=', False)] - if not ops.pack_lot_ids: - preferred_domain_list = [preferred_domain] + [fallback_domain] + [fallback_domain2] - quants = quant_obj.quants_get_preferred_domain(cr, uid, move_qty_ops[move], move, ops=ops, domain=main_domain, - preferred_domain_list=preferred_domain_list, context=context) - quant_obj.quants_move(cr, uid, quants, move, ops.location_dest_id, location_from=ops.location_id, - lot_id=False, owner_id=ops.owner_id.id, src_package_id=ops.package_id.id, - dest_package_id=quant_dest_package_id, entire_pack=entire_pack, context=context) - else: - # Check what you can do with reserved quants already - qty_on_link = move_qty_ops[move] - rounding = ops.product_id.uom_id.rounding - for reserved_quant in move.reserved_quant_ids: - if (reserved_quant.owner_id.id != ops.owner_id.id) or (reserved_quant.location_id.id != ops.location_id.id) or \ - (reserved_quant.package_id.id != ops.package_id.id): - continue - if not reserved_quant.lot_id: - false_quants += [reserved_quant] - elif float_compare(lot_qty.get(reserved_quant.lot_id.id, 0), 0, precision_rounding=rounding) > 0: - if float_compare(lot_qty[reserved_quant.lot_id.id], reserved_quant.qty, precision_rounding=rounding) >= 0: - lot_qty[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_qty[reserved_quant.lot_id.id])] - lot_qty[reserved_quant.lot_id.id] = 0 - qty_on_link -= lot_qty[reserved_quant.lot_id.id] - lot_move_qty[move.id] = qty_on_link - - if not 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)) - move_qty[move.id] -= move_qty_ops[move] - - #Handle lots separately - if ops.pack_lot_ids: - self._move_quants_by_lot(cr, uid, ops, lot_qty, quants_taken, false_quants, lot_move_qty, quant_dest_package_id, context=context) - - # Handle pack in pack - if not ops.product_id and ops.package_id and ops.result_package_id.id != ops.package_id.parent_id.id: - self.pool.get('stock.quant.package').write(cr, SUPERUSER_ID, [ops.package_id.id], {'parent_id': ops.result_package_id.id}, context=context) - #Check for remaining qtys and unreserve/check move_dest_id in - move_dest_ids = set() - for move in self.browse(cr, uid, ids, context=context): - move_qty_cmp = float_compare(move_qty[move.id], 0, precision_rounding=move.product_id.uom_id.rounding) - if move_qty_cmp > 0: # (=In case no pack operations in picking) - main_domain = [('qty', '>', 0)] - preferred_domain = [('reservation_id', '=', move.id)] - fallback_domain = [('reservation_id', '=', False)] - fallback_domain2 = ['&', ('reservation_id', '!=', move.id), ('reservation_id', '!=', False)] - preferred_domain_list = [preferred_domain] + [fallback_domain] + [fallback_domain2] - self.check_tracking(cr, uid, [move.id], False, context=context) - qty = move_qty[move.id] - quants = quant_obj.quants_get_preferred_domain(cr, uid, qty, move, domain=main_domain, preferred_domain_list=preferred_domain_list, context=context) - quant_obj.quants_move(cr, uid, quants, move, move.location_dest_id, lot_id=move.restrict_lot_id.id, owner_id=move.restrict_partner_id.id, context=context) - - # 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: - procurement_ids.add(move.procurement_id.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._check_package_from_moves(cr, uid, ids, context=context) - #set the move as done - self.write(cr, uid, ids, {'state': 'done', 'date': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)}, context=context) - self.pool.get('procurement.order').check(cr, uid, list(procurement_ids), context=context) - #assign destination moves - if move_dest_ids: - self.action_assign(cr, uid, list(move_dest_ids), context=context) - #check picking state to set the date_done is needed - done_picking = [] - for picking in picking_obj.browse(cr, uid, list(pickings), context=context): - if picking.state == 'done' and not picking.date_done: - done_picking.append(picking.id) - if done_picking: - picking_obj.write(cr, uid, done_picking, {'date_done': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)}, context=context) - return True - - def unlink(self, cr, uid, ids, context=None): - context = context or {} - for move in self.browse(cr, uid, ids, context=context): - if move.state not in ('draft', 'cancel'): - raise UserError(_('You can only delete draft moves.')) - return super(stock_move, self).unlink(cr, uid, ids, context=context) - - def action_scrap(self, cr, uid, ids, quantity, location_id, restrict_lot_id=False, restrict_partner_id=False, context=None): - """ Move the scrap/damaged product into scrap location - @param cr: the database cursor - @param uid: the user id - @param ids: ids of stock move object to be scrapped - @param quantity : specify scrap qty - @param location_id : specify scrap location - @param context: context arguments - @return: Scraped lines - """ - quant_obj = self.pool.get("stock.quant") - #quantity should be given in MOVE UOM - if quantity <= 0: - raise UserError(_('Please provide a positive quantity to scrap.')) - res = [] - for move in self.browse(cr, uid, ids, context=context): - source_location = move.location_id - if move.state == 'done': - source_location = move.location_dest_id - #Previously used to prevent scraping from virtual location but not necessary anymore - #if source_location.usage != 'internal': - #restrict to scrap from a virtual location because it's meaningless and it may introduce errors in stock ('creating' new products from nowhere) - #raise UserError(_('Forbidden operation: it is not allowed to scrap products from a virtual location.')) - move_qty = move.product_qty - default_val = { - 'location_id': source_location.id, - 'product_uom_qty': quantity, - 'state': move.state, - 'scrapped': True, - 'location_dest_id': location_id, - 'restrict_lot_id': restrict_lot_id, - 'restrict_partner_id': restrict_partner_id, - } - new_move = self.copy(cr, uid, move.id, default_val) - - res += [new_move] - product_obj = self.pool.get('product.product') - for product in product_obj.browse(cr, uid, [move.product_id.id], context=context): - if move.picking_id: - uom = product.uom_id.name if product.uom_id else '' - message = _("%s %s %s has been moved to scrap.") % (quantity, uom, product.name) - move.picking_id.message_post(body=message) - - # We "flag" the quant from which we want to scrap the products. To do so: - # - we select the quants related to the move we scrap from - # - we reserve the quants with the scrapped move - # See self.action_done, et particularly how is defined the "preferred_domain" for clarification - scrap_move = self.browse(cr, uid, new_move, context=context) - if move.state == 'done' and scrap_move.location_id.usage not in ('supplier', 'inventory', 'production'): - domain = [('qty', '>', 0), ('history_ids', 'in', [move.id])] - # We use scrap_move data since a reservation makes sense for a move not already done - quants = quant_obj.quants_get_preferred_domain(cr, uid, quantity, scrap_move, domain=domain, context=context) - quant_obj.quants_reserve(cr, uid, quants, scrap_move, context=context) - self.action_done(cr, uid, res, context=context) - return res - - def split(self, cr, uid, ids, qty, restrict_lot_id=False, restrict_partner_id=False, context=None): - """ Splits qty from move move into a new move - :param move: browse record - :param qty: float. quantity to split (given in product UoM) - :param restrict_lot_id: optional production lot that can be given in order to force the new move to restrict its choice of quants to this lot. - :param restrict_partner_id: optional partner that can be given in order to force the new move to restrict its choice of quants to the ones belonging to this partner. - :param context: dictionay. can contains the special key 'source_location_id' in order to force the source location when copying the move - - returns the ID of the backorder move created - """ - move = self.browse(cr, uid, ids[0], context=context) - if move.state in ('done', 'cancel'): - raise UserError(_('You cannot split a move done')) - if move.state == 'draft': - #we restrict the split of a draft move because if not confirmed yet, it may be replaced by several other moves in - #case of phantom bom (with mrp module). And we don't want to deal with this complexity by copying the product that will explode. - raise UserError(_('You cannot split a draft move. It needs to be confirmed first.')) - - if move.product_qty <= qty or qty == 0: - return move.id - - uom_obj = self.pool.get('product.uom') - context = context or {} - - #HALF-UP rounding as only rounding errors will be because of propagation of error from default UoM - uom_qty = uom_obj._compute_qty_obj(cr, uid, move.product_id.uom_id, qty, move.product_uom, rounding_method='HALF-UP', context=context) - defaults = { - 'product_uom_qty': uom_qty, - 'procure_method': 'make_to_stock', - 'restrict_lot_id': restrict_lot_id, - 'split_from': move.id, - 'procurement_id': move.procurement_id.id, - 'move_dest_id': move.move_dest_id.id, - 'origin_returned_move_id': move.origin_returned_move_id.id, - } - - if restrict_partner_id: - defaults['restrict_partner_id'] = restrict_partner_id - - if context.get('source_location_id'): - defaults['location_id'] = context['source_location_id'] - new_move = self.copy(cr, uid, move.id, defaults, context=context) - - ctx = context.copy() - ctx['do_not_propagate'] = True - self.write(cr, uid, [move.id], { - 'product_uom_qty': move.product_uom_qty - uom_qty, - }, context=ctx) - - if move.move_dest_id and move.propagate and move.move_dest_id.state not in ('done', 'cancel'): - new_move_prop = self.split(cr, uid, [move.move_dest_id.id], qty, context=context) - self.write(cr, uid, [new_move], {'move_dest_id': new_move_prop}, context=context) - #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) - return self.action_confirm(cr, uid, [new_move], context=context)[0] - - def action_show_picking(self, cr, uid, ids, context=None): - assert len(ids) > 0 - picking_id = self.browse(cr, uid, ids[0], context=context).picking_id.id - if picking_id: - data_obj = self.pool['ir.model.data'] - view = data_obj.xmlid_to_res_id(cr, uid, 'stock.view_picking_form') - return { - 'name': _('Transfer'), - 'type': 'ir.actions.act_window', - 'view_type': 'form', - 'view_mode': 'form', - 'res_model': 'stock.picking', - 'views': [(view, 'form')], - 'view_id': view, - 'target': 'new', - 'res_id': picking_id, - } - show_picking = action_show_picking - - # Quants management - # ---------------------------------------------------------------------- - - def quants_unreserve(self, cr, uid, ids, context=None): - for move in self.browse(cr, uid, ids, context=context): - related_quants = [x.id for x in move.reserved_quant_ids] - if related_quants: - #if move has a picking_id, write on that picking that pack_operation might have changed and need to be recomputed - if move.partially_available: - move.write({'partially_available': False}) - self.pool['stock.quant'].write(cr, SUPERUSER_ID, related_quants, {'reservation_id': False}, context=context) - # WHERE IS ORDERPOINT ? #---------------------------------------------------------- # Stock Warehouse diff --git a/addons/stock/stock_view.xml b/addons/stock/stock_view.xml index e2116807990..b29951b0fd8 100644 --- a/addons/stock/stock_view.xml +++ b/addons/stock/stock_view.xml @@ -87,28 +87,6 @@ # # Lot composition (history) # - - Stock Moves - stock.move - - - - - - - - - - - - - - - - - - - - - - stock.move.pivot - stock.move - - - - - - - - - - - stock.move.graph - stock.move - - - - - - - - - - - stock.move.tree - stock.move - - - - - - - - - - - -