# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from collections import namedtuple import json import time from odoo import api, fields, models, _ from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT from odoo.tools.float_utils import float_compare from odoo.addons.procurement.models import procurement from odoo.exceptions import UserError class PickingType(models.Model): _name = "stock.picking.type" _description = "The picking type determines the picking view" _order = 'sequence, id' name = fields.Char('Picking Type Name', required=True, translate=True) color = fields.Integer('Color') sequence = fields.Integer('Sequence', help="Used to order the 'All Operations' kanban view") sequence_id = fields.Many2one('ir.sequence', 'Reference Sequence', required=True) default_location_src_id = fields.Many2one( 'stock.location', 'Default Source Location', help="This is the default source location when you create a picking manually with this picking type. It is possible however to change it or that the routes put another location. If it is empty, it will check for the supplier location on the partner. ") default_location_dest_id = fields.Many2one( 'stock.location', 'Default Destination Location', help="This is the default destination location when you create a picking manually with this picking type. It is possible however to change it or that the routes put another location. If it is empty, it will check for the customer location on the partner. ") code = fields.Selection([('incoming', 'Vendors'), ('outgoing', 'Customers'), ('internal', 'Internal')], 'Type of Operation', required=True) return_picking_type_id = fields.Many2one('stock.picking.type', 'Picking Type for Returns') show_entire_packs = fields.Boolean('Allow moving packs', help="If checked, this shows the packs to be moved as a whole in the Operations tab all the time, even if there was no entire pack reserved.") warehouse_id = fields.Many2one( 'stock.warehouse', 'Warehouse', ondelete='cascade', default=lambda self: self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1)) active = fields.Boolean('Active', default=True) use_create_lots = fields.Boolean( 'Create New Lots/Serial Numbers', default=True, help="If this is checked only, it will suppose you want to create new Lots/Serial Numbers, so you can provide them in a text field. ") use_existing_lots = fields.Boolean( 'Use Existing Lots/Serial Numbers', default=True, help="If this is checked, you will be able to choose the Lots/Serial Numbers. You can also decide to not put lots in this picking type. This means it will create stock with no lot or not put a restriction on the lot taken. ") # Statistics for the kanban view last_done_picking = fields.Char('Last 10 Done Pickings', compute='_compute_last_done_picking') count_picking_draft = fields.Integer(compute='_compute_picking_count') count_picking_ready = fields.Integer(compute='_compute_picking_count') count_picking = fields.Integer(compute='_compute_picking_count') count_picking_waiting = fields.Integer(compute='_compute_picking_count') count_picking_late = fields.Integer(compute='_compute_picking_count') count_picking_backorders = fields.Integer(compute='_compute_picking_count') rate_picking_late = fields.Integer(compute='_compute_picking_count') rate_picking_backorders = fields.Integer(compute='_compute_picking_count') barcode_nomenclature_id = fields.Many2one( 'barcode.nomenclature', 'Barcode Nomenclature') @api.one def _compute_last_done_picking(self): # TDE TODO: true multi tristates = [] for picking in self.env['stock.picking'].search([('picking_type_id', '=', self.id), ('state', '=', 'done')], order='date_done desc', limit=10): if picking.date_done > picking.date: tristates.insert(0, {'tooltip': picking.name or '' + ": " + _('Late'), 'value': -1}) elif picking.backorder_id: tristates.insert(0, {'tooltip': picking.name or '' + ": " + _('Backorder exists'), 'value': 0}) else: tristates.insert(0, {'tooltip': picking.name or '' + ": " + _('OK'), 'value': 1}) self.last_done_picking = json.dumps(tristates) @api.multi def _compute_picking_count(self): # TDE TODO count picking can be done using previous two domains = { 'count_picking_draft': [('state', '=', 'draft')], 'count_picking_waiting': [('state', 'in', ('confirmed', 'waiting'))], 'count_picking_ready': [('state', 'in', ('assigned', 'partially_available'))], 'count_picking': [('state', 'in', ('assigned', 'waiting', 'confirmed', 'partially_available'))], 'count_picking_late': [('min_date', '<', time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)), ('state', 'in', ('assigned', 'waiting', 'confirmed', 'partially_available'))], 'count_picking_backorders': [('backorder_id', '!=', False), ('state', 'in', ('confirmed', 'assigned', 'waiting', 'partially_available'))], } for field in domains: data = self.env['stock.picking'].read_group(domains[field] + [('state', 'not in', ('done', 'cancel')), ('picking_type_id', 'in', self.ids)], ['picking_type_id'], ['picking_type_id']) count = dict(map(lambda x: (x['picking_type_id'] and x['picking_type_id'][0], x['picking_type_id_count']), data)) for record in self: record[field] = count.get(record.id, 0) for record in self: record.rate_picking_late = record.count_picking and record.count_picking_late * 100 / record.count_picking or 0 record.rate_picking_backorders = record.count_picking and record.count_picking_backorders * 100 / record.count_picking or 0 @api.multi def name_get(self): """ Display 'Warehouse_name: PickingType_name' """ # TDE TODO remove context key support + update purchase res = [] for picking_type in self: if self.env.context.get('special_shortened_wh_name'): if picking_type.warehouse_id: name = picking_type.warehouse_id.name else: name = _('Customer') + ' (' + picking_type.name + ')' elif picking_type.warehouse_id: name = picking_type.warehouse_id.name + ': ' + picking_type.name else: name = picking_type.name res.append((picking_type.id, name)) return res @api.model def name_search(self, name, args=None, operator='ilike', limit=100): args = args or [] domain = [] if name: domain = ['|', ('name', operator, name), ('warehouse_id.name', operator, name)] picks = self.search(domain + args, limit=limit) return picks.name_get() @api.onchange('code') def onchange_picking_code(self): if self.code == 'incoming': self.default_location_src_id = self.env.ref('stock.stock_location_suppliers').id self.default_location_dest_id = self.env.ref('stock.stock_location_stock').id elif self.code == 'outgoing': self.default_location_src_id = self.env.ref('stock.stock_location_stock').id self.default_location_dest_id = self.env.ref('stock.stock_location_customers').id @api.multi def _get_action(self, action_xmlid): # TDE TODO check to have one view + custo in methods action = self.env.ref(action_xmlid).read()[0] if self: action['display_name'] = self.display_name return action @api.multi def get_action_picking_tree_late(self): return self._get_action('stock.action_picking_tree_late') @api.multi def get_action_picking_tree_backorder(self): return self._get_action('stock.action_picking_tree_backorder') @api.multi def get_action_picking_tree_waiting(self): return self._get_action('stock.action_picking_tree_waiting') @api.multi def get_action_picking_tree_ready(self): return self._get_action('stock.action_picking_tree_ready') @api.multi def get_stock_picking_action_picking_type(self): return self._get_action('stock.stock_picking_action_picking_type') class Picking(models.Model): _name = "stock.picking" _inherit = ['mail.thread'] _description = "Transfer" _order = "priority desc, date asc, id desc" name = fields.Char( 'Reference', default='/', copy=False, index=True, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) origin = fields.Char( 'Source Document', index=True, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, help="Reference of the document") note = fields.Text('Notes') backorder_id = fields.Many2one( 'stock.picking', 'Back Order of', copy=False, index=True, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, help="If this shipment was split, then this field links to the shipment which contains the already processed part.") move_type = fields.Selection([ ('direct', 'Partial'), ('one', 'All at once')], 'Delivery Type', default='direct', required=True, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, help="It specifies goods to be deliver partially or all at once") state = fields.Selection([ ('draft', 'Draft'), ('cancel', 'Cancelled'), ('waiting', 'Waiting Another Operation'), ('confirmed', 'Waiting Availability'), ('partially_available', 'Partially Available'), ('assigned', 'Available'), ('done', 'Done')], string='Status', compute='_compute_state', copy=False, index=True, readonly=True, store=True, track_visibility='onchange', help=" * Draft: not confirmed yet and will not be scheduled until confirmed\n" " * Waiting Another Operation: waiting for another move to proceed before it becomes automatically available (e.g. in Make-To-Order flows)\n" " * Waiting Availability: still waiting for the availability of products\n" " * Partially Available: some products are available and reserved\n" " * Ready to Transfer: products reserved, simply waiting for confirmation.\n" " * Transferred: has been processed, can't be modified or cancelled anymore\n" " * Cancelled: has been cancelled, can't be confirmed anymore") group_id = fields.Many2one( 'procurement.group', 'Procurement Group', readonly=True, related='move_lines.group_id', store=True) priority = fields.Selection( procurement.PROCUREMENT_PRIORITIES, string='Priority', compute='_compute_priority', inverse='_set_priority', store=True, # default='1', required=True, # TDE: required, depending on moves ? strange index=True, track_visibility='onchange', states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, help="Priority for this picking. Setting manually a value here would set it as priority for all the moves") min_date = fields.Datetime( 'Scheduled Date', compute='_compute_dates', inverse='_set_min_date', store=True, index=True, track_visibility='onchange', states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, help="Scheduled time for the first part of the shipment to be processed. Setting manually a value here would set it as expected date for all the stock moves.") max_date = fields.Datetime( 'Max. Expected Date', compute='_compute_dates', store=True, index=True, help="Scheduled time for the last part of the shipment to be processed") date = fields.Datetime( 'Creation Date', default=fields.Datetime.now, index=True, track_visibility='onchange', states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, help="Creation Date, usually the time of the order") date_done = fields.Datetime('Date of Transfer', copy=False, readonly=True, help="Completion Date of Transfer") location_id = fields.Many2one( 'stock.location', "Source Location Zone", default=lambda self: self.env['stock.picking.type'].browse(self._context.get('default_picking_type_id')).default_location_src_id, readonly=True, required=True, states={'draft': [('readonly', False)]}) location_dest_id = fields.Many2one( 'stock.location', "Destination Location Zone", default=lambda self: self.env['stock.picking.type'].browse(self._context.get('default_picking_type_id')).default_location_dest_id, readonly=True, required=True, states={'draft': [('readonly', False)]}) move_lines = fields.One2many('stock.move', 'picking_id', string="Stock Moves", copy=True) has_scrap_move = fields.Boolean( 'Has Scrap Moves', compute='_has_scrap_move') picking_type_id = fields.Many2one( 'stock.picking.type', 'Picking Type', required=True, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) picking_type_code = fields.Selection([ ('incoming', 'Vendors'), ('outgoing', 'Customers'), ('internal', 'Internal')], related='picking_type_id.code', readonly=True) picking_type_entire_packs = fields.Boolean(related='picking_type_id.show_entire_packs', readonly=True) quant_reserved_exist = fields.Boolean( 'Has quants already reserved', compute='_compute_quant_reserved_exist', help='Check the existance of quants linked to this picking') partner_id = fields.Many2one( 'res.partner', 'Partner', states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) company_id = fields.Many2one( 'res.company', 'Company', default=lambda self: self.env['res.company']._company_default_get('stock.picking'), index=True, required=True, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) # TDE FIXME: separate those two kind of pack operations pack_operation_ids = fields.One2many( 'stock.pack.operation', 'picking_id', 'Related Packing Operations', states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) pack_operation_product_ids = fields.One2many( 'stock.pack.operation', 'picking_id', 'Non pack', domain=[('product_id', '!=', False)], states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) pack_operation_pack_ids = fields.One2many( 'stock.pack.operation', 'picking_id', 'Pack', domain=[('product_id', '=', False)], states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) pack_operation_exist = fields.Boolean( 'Has Pack Operations', compute='_compute_pack_operation_exist', help='Check the existence of pack operation on the picking') owner_id = fields.Many2one( 'res.partner', 'Owner', states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, help="Default Owner") printed = fields.Boolean('Printed') # Used to search on pickings product_id = fields.Many2one('product.product', 'Product', related='move_lines.product_id') recompute_pack_op = fields.Boolean( 'Recompute pack operation?', copy=False, help='True if reserved quants changed, which mean we might need to recompute the package operations') launch_pack_operations = fields.Boolean("Launch Pack Operations", copy=False) _sql_constraints = [ ('name_uniq', 'unique(name, company_id)', 'Reference must be unique per company!'), ] @api.depends('move_type', 'launch_pack_operations', 'move_lines.state', 'move_lines.picking_id', 'move_lines.partially_available') @api.one def _compute_state(self): ''' State of a picking depends on the state of its related stock.move - no moves: draft or assigned (launch_pack_operations) - all moves canceled: cancel - all moves done (including possible canceled): done - All at once picking: least of confirmed / waiting / assigned - Partial picking - all moves assigned: assigned - one of the move is assigned or partially available: partially available - otherwise in waiting or confirmed state ''' if not self.move_lines and self.launch_pack_operations: self.state = 'assigned' elif not self.move_lines: self.state = 'draft' elif any(move.state == 'draft' for move in self.move_lines): # TDE FIXME: should be all ? self.state = 'draft' elif all(move.state == 'cancel' for move in self.move_lines): self.state = 'cancel' elif all(move.state in ['cancel', 'done'] for move in self.move_lines): self.state = 'done' else: # We sort our moves by importance of state: "confirmed" should be first, then we'll have # "waiting" and finally "assigned" at the end. moves_todo = self.move_lines\ .filtered(lambda move: move.state not in ['cancel', 'done'])\ .sorted(key=lambda move: (move.state == 'assigned' and 2) or (move.state == 'waiting' and 1) or 0) if self.move_type == 'one': self.state = moves_todo[0].state or 'draft' elif moves_todo[0].state != 'assigned' and any(x.partially_available or x.state == 'assigned' for x in moves_todo): self.state = 'partially_available' else: self.state = moves_todo[-1].state or 'draft' @api.one @api.depends('move_lines.priority') def _compute_priority(self): self.priority = self.mapped('move_lines') and max(self.mapped('move_lines').mapped('priority')) or '1' @api.one def _set_priority(self): self.move_lines.write({'priority': self.priority}) @api.one @api.depends('move_lines.date_expected') def _compute_dates(self): self.min_date = min(self.move_lines.mapped('date_expected') or [False]) self.max_date = max(self.move_lines.mapped('date_expected') or [False]) @api.one def _set_min_date(self): self.move_lines.write({'date_expected': self.min_date}) @api.one def _has_scrap_move(self): # TDE FIXME: better implementation self.has_scrap_move = bool(self.env['stock.move'].search_count([('picking_id', '=', self.id), ('scrapped', '=', True)])) @api.one def _compute_quant_reserved_exist(self): # TDE TODO: chould probably be cleaned with a search in quants self.quant_reserved_exist = any(move.reserved_quant_ids for move in self.mapped('move_lines')) @api.one def _compute_pack_operation_exist(self): self.pack_operation_exist = bool(self.pack_operation_ids) @api.onchange('picking_type_id', 'partner_id') def onchange_picking_type(self): if self.picking_type_id: if self.picking_type_id.default_location_src_id: location_id = self.picking_type_id.default_location_src_id.id elif self.partner_id: location_id = self.partner_id.property_stock_supplier.id else: customerloc, location_id = self.env['stock.warehouse']._get_partner_locations() if self.picking_type_id.default_location_dest_id: location_dest_id = self.picking_type_id.default_location_dest_id.id elif self.partner_id: location_dest_id = self.partner_id.property_stock_customer.id else: location_dest_id, supplierloc = self.env['stock.warehouse']._get_partner_locations() self.location_id = location_id self.location_dest_id = location_dest_id # TDE CLEANME move into onchange_partner_id if self.partner_id: if self.partner_id.picking_warn == 'no-message' and self.partner_id.parent_id: partner = self.partner_id.parent_id elif self.partner_id.picking_warn not in ('no-message', 'block') and self.partner_id.parent_id.picking_warn == 'block': partner = self.partner_id.parent_id else: partner = self.partner_id if partner.picking_warn != 'no-message': if partner.picking_warn == 'block': self.partner_id = False return {'warning': { 'title': ("Warning for %s") % partner.name, 'message': partner.picking_warn_msg }} @api.model def create(self, vals): # TDE FIXME: clean that brol defaults = self.default_get(['name', 'picking_type_id']) if vals.get('name', '/') == '/' and defaults.get('name', '/') == '/' and vals.get('picking_type_id', defaults.get('picking_type_id')): vals['name'] = self.env['stock.picking.type'].browse(vals.get('picking_type_id', defaults.get('picking_type_id'))).sequence_id.next_by_id() # TDE FIXME: what ? # As the on_change in one2many list is WIP, we will overwrite the locations on the stock moves here # As it is a create the format will be a list of (0, 0, dict) if vals.get('move_lines') and vals.get('location_id') and vals.get('location_dest_id'): for move in vals['move_lines']: if len(move) == 3: move[2]['location_id'] = vals['location_id'] move[2]['location_dest_id'] = vals['location_dest_id'] return super(Picking, self).create(vals) @api.multi def write(self, vals): res = super(Picking, self).write(vals) # Change locations of moves if those of the picking change after_vals = {} if vals.get('location_id'): after_vals['location_id'] = vals['location_id'] if vals.get('location_dest_id'): after_vals['location_dest_id'] = vals['location_dest_id'] if after_vals: self.mapped('move_lines').filtered(lambda move: not move.scrapped).write(after_vals) return res @api.multi def unlink(self): self.mapped('move_lines').action_cancel() self.mapped('move_lines').unlink() # Checks if moves are not done return super(Picking, self).unlink() # Actions # ---------------------------------------- @api.one def action_assign_owner(self): self.pack_operation_ids.write({'owner_id': self.owner_id.id}) @api.multi def do_print_picking(self): self.write({'printed': True}) return self.env["report"].get_action(self, 'stock.report_picking') @api.multi def action_confirm(self): self.filtered(lambda picking: not picking.move_lines).write({'launch_pack_operations': True}) # TDE CLEANME: use of launch pack operation, really useful ? self.mapped('move_lines').filtered(lambda move: move.state == 'draft').action_confirm() self.filtered(lambda picking: picking.location_id.usage in ('supplier', 'inventory', 'production')).force_assign() return True @api.multi def action_assign(self): """ Check availability of picking moves. This has the effect of changing the state and reserve quants on available moves, and may also impact the state of the picking as it is computed based on move's states. @return: True """ self.filtered(lambda picking: picking.state == 'draft').action_confirm() moves = self.mapped('move_lines').filtered(lambda move: move.state not in ('draft', 'cancel', 'done')) if not moves: raise UserError(_('Nothing to check the availability for.')) moves.action_assign() return True @api.multi def force_assign(self): """ Changes state of picking to available if moves are confirmed or waiting. @return: True """ self.mapped('move_lines').filtered(lambda move: move.state in ['confirmed', 'waiting']).force_assign() return True @api.multi def action_cancel(self): self.mapped('move_lines').action_cancel() return True @api.multi def action_done(self): """Changes picking state to done by processing the Stock Moves of the Picking Normally that happens when the button "Done" is pressed on a Picking view. @return: True """ # TDE FIXME: remove decorator when migration the remaining # TDE FIXME: draft -> automatically done, if waiting ?? CLEAR ME draft_moves = self.mapped('move_lines').filtered(lambda self: self.state == 'draft') todo_moves = self.mapped('move_lines').filtered(lambda self: self.state in ['draft', 'assigned', 'confirmed']) draft_moves.action_confirm() todo_moves.action_done() return True @api.multi def recheck_availability(self): self.action_assign() self.do_prepare_partial() def _prepare_pack_ops(self, quants, forced_qties): """ Prepare pack_operations, returns a list of dict to give at create """ # TDE CLEANME: oh dear ... valid_quants = quants.filtered(lambda quant: quant.qty > 0) _Mapping = namedtuple('Mapping', ('product', 'package', 'owner', 'location', 'location_dst_id')) all_products = valid_quants.mapped('product_id') | self.env['product.product'].browse(p.id for p in forced_qties.keys()) | self.move_lines.mapped('product_id') computed_putaway_locations = dict( (product, self.location_dest_id.get_putaway_strategy(product) or self.location_dest_id.id) for product in all_products) product_to_uom = dict((product.id, product.uom_id) for product in all_products) picking_moves = self.move_lines.filtered(lambda move: move.state not in ('done', 'cancel')) for move in picking_moves: # If we encounter an UoM that is smaller than the default UoM or the one already chosen, use the new one instead. if move.product_uom != product_to_uom[move.product_id.id] and move.product_uom.factor > product_to_uom[move.product_id.id].factor: product_to_uom[move.product_id.id] = move.product_uom if len(picking_moves.mapped('location_id')) > 1: raise UserError(_('The source location must be the same for all the moves of the picking.')) if len(picking_moves.mapped('location_dest_id')) > 1: raise UserError(_('The destination location must be the same for all the moves of the picking.')) pack_operation_values = [] # find the packages we can move as a whole, create pack operations and mark related quants as done top_lvl_packages = valid_quants._get_top_level_packages(computed_putaway_locations) for pack in top_lvl_packages: pack_quants = pack.get_content() pack_operation_values.append({ 'picking_id': self.id, 'package_id': pack.id, 'product_qty': 1.0, 'location_id': pack.location_id.id, 'location_dest_id': computed_putaway_locations[pack_quants[0].product_id], 'owner_id': pack.owner_id.id, }) valid_quants -= pack_quants # Go through all remaining reserved quants and group by product, package, owner, source location and dest location # Lots will go into pack operation lot object qtys_grouped = {} lots_grouped = {} for quant in valid_quants: key = _Mapping(quant.product_id, quant.package_id, quant.owner_id, quant.location_id, computed_putaway_locations[quant.product_id]) qtys_grouped.setdefault(key, 0.0) qtys_grouped[key] += quant.qty if quant.product_id.tracking != 'none' and quant.lot_id: lots_grouped.setdefault(key, dict()).setdefault(quant.lot_id.id, 0.0) lots_grouped[key][quant.lot_id.id] += quant.qty # Do the same for the forced quantities (in cases of force_assign or incomming shipment for example) for product, qty in forced_qties.items(): if qty <= 0.0: continue key = _Mapping(product, self.env['stock.quant.package'], self.owner_id, self.location_id, computed_putaway_locations[product]) qtys_grouped.setdefault(key, 0.0) qtys_grouped[key] += qty # Create the necessary operations for the grouped quants and remaining qtys Uom = self.env['product.uom'] product_id_to_vals = {} # use it to create operations using the same order as the picking stock moves for mapping, qty in qtys_grouped.items(): uom = product_to_uom[mapping.product.id] val_dict = { 'picking_id': self.id, 'product_qty': mapping.product.uom_id._compute_quantity(qty, uom), 'product_id': mapping.product.id, 'package_id': mapping.package.id, 'owner_id': mapping.owner.id, 'location_id': mapping.location.id, 'location_dest_id': mapping.location_dst_id, 'product_uom_id': uom.id, 'pack_lot_ids': [ (0, 0, { 'lot_id': lot, 'qty': 0.0, 'qty_todo': mapping.product.uom_id._compute_quantity(lots_grouped[mapping][lot], uom) }) for lot in lots_grouped.get(mapping, {}).keys()], } product_id_to_vals.setdefault(mapping.product.id, list()).append(val_dict) for move in self.move_lines.filtered(lambda move: move.state not in ('done', 'cancel')): values = product_id_to_vals.pop(move.product_id.id, []) pack_operation_values += values return pack_operation_values @api.multi def do_prepare_partial(self): # TDE CLEANME: oh dear ... PackOperation = self.env['stock.pack.operation'] # get list of existing operations and delete them existing_packages = PackOperation.search([('picking_id', 'in', self.ids)]) # TDE FIXME: o2m / m2o ? if existing_packages: existing_packages.unlink() for picking in self: forced_qties = {} # Quantity remaining after calculating reserved quants picking_quants = self.env['stock.quant'] # Calculate packages, reserved quants, qtys of this picking's moves for move in picking.move_lines: if move.state not in ('assigned', 'confirmed', 'waiting'): continue move_quants = move.reserved_quant_ids picking_quants += move_quants forced_qty = 0.0 if move.state == 'assigned': qty = move.product_uom._compute_quantity(move.product_uom_qty, move.product_id.uom_id, round=False) forced_qty = qty - sum([x.qty for x in move_quants]) # if we used force_assign() on the move, or if the move is incoming, forced_qty > 0 if float_compare(forced_qty, 0, precision_rounding=move.product_id.uom_id.rounding) > 0: if forced_qties.get(move.product_id): forced_qties[move.product_id] += forced_qty else: forced_qties[move.product_id] = forced_qty for vals in picking._prepare_pack_ops(picking_quants, forced_qties): vals['fresh_record'] = False PackOperation |= PackOperation.create(vals) # recompute the remaining quantities all at once self.do_recompute_remaining_quantities() for pack in PackOperation: pack.ordered_qty = sum( pack.mapped('linked_move_operation_ids').mapped('move_id').filtered(lambda r: r.state != 'cancel').mapped('ordered_qty') ) self.write({'recompute_pack_op': False}) @api.multi def do_unreserve(self): """ Will remove all quants for picking in picking_ids """ moves_to_unreserve = self.mapped('move_lines').filtered(lambda move: move.state not in ('done', 'cancel')) pack_line_to_unreserve = self.mapped('pack_operation_ids') if moves_to_unreserve: if pack_line_to_unreserve: pack_line_to_unreserve.unlink() moves_to_unreserve.do_unreserve() def recompute_remaining_qty(self, done_qtys=False): def _create_link_for_index(operation_id, index, product_id, qty_to_assign, quant_id=False): move_dict = prod2move_ids[product_id][index] qty_on_link = min(move_dict['remaining_qty'], qty_to_assign) self.env['stock.move.operation.link'].create({'move_id': move_dict['move'].id, 'operation_id': operation_id, 'qty': qty_on_link, 'reserved_quant_id': quant_id}) if float_compare(move_dict['remaining_qty'], qty_on_link, precision_rounding=move_dict['move'].product_uom.rounding) == 0: prod2move_ids[product_id].pop(index) else: move_dict['remaining_qty'] -= qty_on_link return qty_on_link def _create_link_for_quant(operation_id, quant, qty): """create a link for given operation and reserved move of given quant, for the max quantity possible, and returns this quantity""" if not quant.reservation_id.id: return _create_link_for_product(operation_id, quant.product_id.id, qty) qty_on_link = 0 for i in range(0, len(prod2move_ids[quant.product_id.id])): if prod2move_ids[quant.product_id.id][i]['move'].id != quant.reservation_id.id: continue qty_on_link = _create_link_for_index(operation_id, i, quant.product_id.id, qty, quant_id=quant.id) break return qty_on_link def _create_link_for_product(operation_id, product_id, qty): '''method that creates the link between a given operation and move(s) of given product, for the given quantity. Returns True if it was possible to create links for the requested quantity (False if there was not enough quantity on stock moves)''' qty_to_assign = qty Product = self.env["product.product"] product = Product.browse(product_id) rounding = product.uom_id.rounding qtyassign_cmp = float_compare(qty_to_assign, 0.0, precision_rounding=rounding) if prod2move_ids.get(product_id): while prod2move_ids[product_id] and qtyassign_cmp > 0: qty_on_link = _create_link_for_index(operation_id, 0, product_id, qty_to_assign, quant_id=False) qty_to_assign -= qty_on_link qtyassign_cmp = float_compare(qty_to_assign, 0.0, precision_rounding=rounding) return qtyassign_cmp == 0 # TDE CLEANME: oh dear ... Uom = self.env['product.uom'] QuantPackage = self.env['stock.quant.package'] OperationLink = self.env['stock.move.operation.link'] quants_in_package_done = set() prod2move_ids = {} still_to_do = [] # make a dictionary giving for each product, the moves and related quantity that can be used in operation links moves = sorted([x for x in self.move_lines if x.state not in ('done', 'cancel')], key=lambda x: (((x.state == 'assigned') and -2 or 0) + (x.partially_available and -1 or 0))) for move in moves: if not prod2move_ids.get(move.product_id.id): prod2move_ids[move.product_id.id] = [{'move': move, 'remaining_qty': move.product_qty}] else: prod2move_ids[move.product_id.id].append({'move': move, 'remaining_qty': move.product_qty}) need_rereserve = False # sort the operations in order to give higher priority to those with a package, then a lot/serial number operations = self.pack_operation_ids operations = sorted(operations, key=lambda x: ((x.package_id and not x.product_id) and -4 or 0) + (x.package_id and -2 or 0) + (x.pack_lot_ids and -1 or 0)) # delete existing operations to start again from scratch links = OperationLink.search([('operation_id', 'in', [x.id for x in operations])]) if links: links.unlink() # 1) first, try to create links when quants can be identified without any doubt for ops in operations: lot_qty = {} for packlot in ops.pack_lot_ids: lot_qty[packlot.lot_id.id] = ops.product_uom_id._compute_quantity(packlot.qty, ops.product_id.uom_id) # for each operation, create the links with the stock move by seeking on the matching reserved quants, # and deffer the operation if there is some ambiguity on the move to select if ops.package_id and not ops.product_id and (not done_qtys or ops.qty_done): # entire package for quant in ops.package_id.get_content(): remaining_qty_on_quant = quant.qty if quant.reservation_id: # avoid quants being counted twice quants_in_package_done.add(quant.id) qty_on_link = _create_link_for_quant(ops.id, quant, quant.qty) remaining_qty_on_quant -= qty_on_link if remaining_qty_on_quant: still_to_do.append((ops, quant.product_id.id, remaining_qty_on_quant)) need_rereserve = True elif ops.product_id.id: # Check moves with same product product_qty = ops.qty_done if done_qtys else ops.product_qty qty_to_assign = ops.product_uom_id._compute_quantity(product_qty, ops.product_id.uom_id) precision_rounding = ops.product_id.uom_id.rounding for move_dict in prod2move_ids.get(ops.product_id.id, []): move = move_dict['move'] for quant in move.reserved_quant_ids: if float_compare(qty_to_assign, 0, precision_rounding=precision_rounding) != 1: break if quant.id in quants_in_package_done: continue # check if the quant is matching the operation details if ops.package_id: flag = quant.package_id == ops.package_id else: flag = not quant.package_id.id flag = flag and (ops.owner_id.id == quant.owner_id.id) if flag: if not lot_qty: max_qty_on_link = min(quant.qty, qty_to_assign) qty_on_link = _create_link_for_quant(ops.id, quant, max_qty_on_link) qty_to_assign -= qty_on_link else: if lot_qty.get(quant.lot_id.id): # if there is still some qty left max_qty_on_link = min(quant.qty, qty_to_assign, lot_qty[quant.lot_id.id]) qty_on_link = _create_link_for_quant(ops.id, quant, max_qty_on_link) qty_to_assign -= qty_on_link lot_qty[quant.lot_id.id] -= qty_on_link qty_assign_cmp = float_compare(qty_to_assign, 0, precision_rounding=precision_rounding) if qty_assign_cmp > 0: # qty reserved is less than qty put in operations. We need to create a link but it's deferred after we processed # all the quants (because they leave no choice on their related move and needs to be processed with higher priority) still_to_do += [(ops, ops.product_id.id, qty_to_assign)] need_rereserve = True # 2) then, process the remaining part all_op_processed = True for ops, product_id, remaining_qty in still_to_do: all_op_processed = _create_link_for_product(ops.id, product_id, remaining_qty) and all_op_processed return (need_rereserve, all_op_processed) def picking_recompute_remaining_quantities(self, done_qtys=False): need_rereserve = False all_op_processed = True if self.pack_operation_ids: need_rereserve, all_op_processed = self.recompute_remaining_qty(done_qtys=done_qtys) return need_rereserve, all_op_processed def do_recompute_remaining_quantities(self, done_qtys=False): # TDE FIXME tmp = self.filtered(lambda picking: picking.pack_operation_ids) if tmp: for pick in tmp: pick.recompute_remaining_qty(done_qtys=done_qtys) def rereserve_quants(self, move_ids=[]): """ Unreserve quants then try to reassign quants.""" if not move_ids: self.do_unreserve() self.action_assign() else: moves = self.env['stock.move'].browse(move_ids) if self.env.context.get('no_state_change'): moves = moves.filtered(lambda m: m.reserved_quant_ids) moves.do_unreserve() moves.action_assign(no_prepare=True) @api.multi def do_new_transfer(self): for pick in self: if pick.state == 'done': raise UserError(_('The pick is already validated')) pack_operations_delete = self.env['stock.pack.operation'] if not pick.move_lines and not pick.pack_operation_ids: raise UserError(_('Please create some Initial Demand or Mark as Todo and create some Operations. ')) # In draft or with no pack operations edited yet, ask if we can just do everything if pick.state == 'draft' or all([x.qty_done == 0.0 for x in pick.pack_operation_ids]): # If no lots when needed, raise error picking_type = pick.picking_type_id if (picking_type.use_create_lots or picking_type.use_existing_lots): for pack in pick.pack_operation_ids: if pack.product_id and pack.product_id.tracking != 'none': raise UserError(_('Some products require lots/serial numbers, so you need to specify those first!')) view = self.env.ref('stock.view_immediate_transfer') wiz = self.env['stock.immediate.transfer'].create({'pick_id': pick.id}) # TDE FIXME: a return in a loop, what a good idea. Really. return { 'name': _('Immediate Transfer?'), 'type': 'ir.actions.act_window', 'view_type': 'form', 'view_mode': 'form', 'res_model': 'stock.immediate.transfer', 'views': [(view.id, 'form')], 'view_id': view.id, 'target': 'new', 'res_id': wiz.id, 'context': self.env.context, } # Check backorder should check for other barcodes if pick.check_backorder(): view = self.env.ref('stock.view_backorder_confirmation') wiz = self.env['stock.backorder.confirmation'].create({'pick_id': pick.id}) # TDE FIXME: same reamrk as above actually return { 'name': _('Create Backorder?'), 'type': 'ir.actions.act_window', 'view_type': 'form', 'view_mode': 'form', 'res_model': 'stock.backorder.confirmation', 'views': [(view.id, 'form')], 'view_id': view.id, 'target': 'new', 'res_id': wiz.id, 'context': self.env.context, } for operation in pick.pack_operation_ids: if operation.qty_done < 0: raise UserError(_('No negative quantities allowed')) if operation.qty_done > 0: operation.write({'product_qty': operation.qty_done}) else: pack_operations_delete |= operation if pack_operations_delete: pack_operations_delete.unlink() self.do_transfer() return def check_backorder(self): need_rereserve, all_op_processed = self.picking_recompute_remaining_quantities(done_qtys=True) for move in self.move_lines: if float_compare(move.remaining_qty, 0, precision_rounding=move.product_id.uom_id.rounding) != 0: return True return False @api.multi def do_transfer(self): """ If no pack operation, we do simple action_done of the picking. Otherwise, do the pack operations. """ # TDE CLEAN ME: reclean me, please self._create_lots_for_picking() no_pack_op_pickings = self.filtered(lambda picking: not picking.pack_operation_ids) no_pack_op_pickings.action_done() other_pickings = self - no_pack_op_pickings for picking in other_pickings: need_rereserve, all_op_processed = picking.picking_recompute_remaining_quantities() todo_moves = self.env['stock.move'] toassign_moves = self.env['stock.move'] # create extra moves in the picking (unexpected product moves coming from pack operations) if not all_op_processed: todo_moves |= picking._create_extra_moves() if need_rereserve or not all_op_processed: moves_reassign = any(x.origin_returned_move_id or x.move_orig_ids for x in picking.move_lines if x.state not in ['done', 'cancel']) if moves_reassign and picking.location_id.usage not in ("supplier", "production", "inventory"): # unnecessary to assign other quants than those involved with pack operations as they will be unreserved anyways. picking.with_context(reserve_only_ops=True, no_state_change=True).rereserve_quants(move_ids=picking.move_lines.ids) picking.do_recompute_remaining_quantities() # split move lines if needed for move in picking.move_lines: rounding = move.product_id.uom_id.rounding remaining_qty = move.remaining_qty if move.state in ('done', 'cancel'): # ignore stock moves cancelled or already done continue elif move.state == 'draft': toassign_moves |= move if float_compare(remaining_qty, 0, precision_rounding=rounding) == 0: if move.state in ('draft', 'assigned', 'confirmed'): todo_moves |= move elif float_compare(remaining_qty, 0, precision_rounding=rounding) > 0 and float_compare(remaining_qty, move.product_qty, precision_rounding=rounding) < 0: # TDE FIXME: shoudl probably return a move - check for no track key, by the way new_move_id = move.split(remaining_qty) new_move = self.env['stock.move'].with_context(mail_notrack=True).browse(new_move_id) todo_moves |= move # Assign move as it was assigned before toassign_moves |= new_move # TDE FIXME: do_only_split does not seem used anymore if todo_moves and not self.env.context.get('do_only_split'): todo_moves.action_done() elif self.env.context.get('do_only_split'): picking = picking.with_context(split=todo_moves.ids) picking._create_backorder() return True def _create_lots_for_picking(self): Lot = self.env['stock.production.lot'] for pack_op_lot in self.mapped('pack_operation_ids').mapped('pack_lot_ids'): if not pack_op_lot.lot_id: lot = Lot.create({'name': pack_op_lot.lot_name, 'product_id': pack_op_lot.operation_id.product_id.id}) pack_op_lot.write({'lot_id': lot.id}) # TDE FIXME: this should not be done here self.mapped('pack_operation_ids').mapped('pack_lot_ids').filtered(lambda op_lot: op_lot.qty == 0.0).unlink() create_lots_for_picking = _create_lots_for_picking def _create_extra_moves(self): '''This function creates move lines on a picking, at the time of do_transfer, based on unexpected product transfers (or exceeding quantities) found in the pack operations. ''' # TDE FIXME: move to batch self.ensure_one() moves = self.env['stock.move'] for pack_operation in self.pack_operation_ids: for product, remaining_qty in pack_operation._get_remaining_prod_quantities().items(): if float_compare(remaining_qty, 0, precision_rounding=product.uom_id.rounding) > 0: vals = self._prepare_values_extra_move(pack_operation, product, remaining_qty) moves |= moves.create(vals) if moves: moves.with_context(skip_check=True).action_confirm() return moves @api.model def _prepare_values_extra_move(self, op, product, remaining_qty): """ Creates an extra move when there is no corresponding original move to be copied """ Uom = self.env["product.uom"] uom_id = product.uom_id.id qty = remaining_qty if op.product_id and op.product_uom_id and op.product_uom_id.id != product.uom_id.id: if op.product_uom_id.factor > product.uom_id.factor: # If the pack operation's is a smaller unit uom_id = op.product_uom_id.id # HALF-UP rounding as only rounding errors will be because of propagation of error from default UoM qty = product.uom_id._compute_quantity(remaining_qty, op.product_uom_id, rounding_method='HALF-UP') picking = op.picking_id ref = product.default_code name = '[' + ref + ']' + ' ' + product.name if ref else product.name proc_id = False for m in op.linked_move_operation_ids: if m.move_id.procurement_id: proc_id = m.move_id.procurement_id.id break return { 'picking_id': picking.id, 'location_id': picking.location_id.id, 'location_dest_id': picking.location_dest_id.id, 'product_id': product.id, 'procurement_id': proc_id, 'product_uom': uom_id, 'product_uom_qty': qty, 'name': _('Extra Move: ') + name, 'state': 'draft', 'restrict_partner_id': op.owner_id.id, 'group_id': picking.group_id.id, } @api.multi def _create_backorder(self, backorder_moves=[]): """ Move all non-done lines into a new backorder picking. If the key 'do_only_split' is given in the context, then move all lines not in context.get('split', []) instead of all non-done lines. """ # TDE note: o2o conversion, todo multi backorders = self.env['stock.picking'] for picking in self: backorder_moves = backorder_moves or picking.move_lines if self._context.get('do_only_split'): not_done_bo_moves = backorder_moves.filtered(lambda move: move.id not in self._context.get('split', [])) else: not_done_bo_moves = backorder_moves.filtered(lambda move: move.state not in ('done', 'cancel')) if not not_done_bo_moves: continue backorder_picking = picking.copy({ 'name': '/', 'move_lines': [], 'pack_operation_ids': [], 'backorder_id': picking.id }) picking.message_post(body=_("Back order %s created.") % (backorder_picking.name)) not_done_bo_moves.write({'picking_id': backorder_picking.id}) if not picking.date_done: picking.write({'date_done': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)}) backorder_picking.action_confirm() backorder_picking.action_assign() backorders |= backorder_picking return backorders @api.multi def put_in_pack(self): # TDE FIXME: reclean me QuantPackage = self.env["stock.quant.package"] package = False for pick in self: operations = [x for x in pick.pack_operation_ids if x.qty_done > 0 and (not x.result_package_id)] pack_operation_ids = self.env['stock.pack.operation'] for operation in operations: # If we haven't done all qty in operation, we have to split into 2 operation op = operation if operation.qty_done < operation.product_qty: new_operation = operation.copy({'product_qty': operation.qty_done,'qty_done': operation.qty_done}) operation.write({'product_qty': operation.product_qty - operation.qty_done,'qty_done': 0}) if operation.pack_lot_ids: packlots_transfer = [(4, x.id) for x in operation.pack_lot_ids] new_operation.write({'pack_lot_ids': packlots_transfer}) # the stock.pack.operation.lot records now belong to the new, packaged stock.pack.operation # we have to create new ones with new quantities for our original, unfinished stock.pack.operation new_operation._copy_remaining_pack_lot_ids(operation) op = new_operation pack_operation_ids |= op if operations: pack_operation_ids.check_tracking() package = QuantPackage.create({}) pack_operation_ids.write({'result_package_id': package.id}) else: raise UserError(_('Please process some quantities to put in the pack first!')) return package @api.multi def button_scrap(self): self.ensure_one() return { 'name': _('Scrap'), 'view_type': 'form', 'view_mode': 'form', 'res_model': 'stock.scrap', 'view_id': self.env.ref('stock.stock_scrap_form_view2').id, 'type': 'ir.actions.act_window', 'context': {'default_picking_id': self.id, 'product_ids': self.pack_operation_product_ids.mapped('product_id').ids}, 'target': 'new', } @api.multi def action_see_move_scrap(self): self.ensure_one() action = self.env.ref('stock.action_stock_scrap').read()[0] scraps = self.env['stock.scrap'].search([('picking_id', '=', self.id)]) action['domain'] = [('id', 'in', scraps.ids)] return action