1# -*- coding: utf-8 -*- 2# Part of Odoo. See LICENSE file for full copyright and licensing details. 3 4import json 5import time 6from ast import literal_eval 7from collections import defaultdict 8from datetime import date 9from itertools import groupby 10from operator import attrgetter, itemgetter 11from collections import defaultdict 12 13from odoo import SUPERUSER_ID, _, api, fields, models 14from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES 15from odoo.exceptions import UserError 16from odoo.osv import expression 17from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, format_datetime 18from odoo.tools.float_utils import float_compare, float_is_zero, float_round 19from odoo.tools.misc import format_date 20 21 22class PickingType(models.Model): 23 _name = "stock.picking.type" 24 _description = "Picking Type" 25 _order = 'sequence, id' 26 _check_company_auto = True 27 28 def _default_show_operations(self): 29 return self.user_has_groups('stock.group_production_lot,' 30 'stock.group_stock_multi_locations,' 31 'stock.group_tracking_lot') 32 33 name = fields.Char('Operation Type', required=True, translate=True) 34 color = fields.Integer('Color') 35 sequence = fields.Integer('Sequence', help="Used to order the 'All Operations' kanban view") 36 sequence_id = fields.Many2one( 37 'ir.sequence', 'Reference Sequence', 38 check_company=True, copy=False) 39 sequence_code = fields.Char('Code', required=True) 40 default_location_src_id = fields.Many2one( 41 'stock.location', 'Default Source Location', 42 check_company=True, 43 help="This is the default source location when you create a picking manually with this operation 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. ") 44 default_location_dest_id = fields.Many2one( 45 'stock.location', 'Default Destination Location', 46 check_company=True, 47 help="This is the default destination location when you create a picking manually with this operation 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. ") 48 code = fields.Selection([('incoming', 'Receipt'), ('outgoing', 'Delivery'), ('internal', 'Internal Transfer')], 'Type of Operation', required=True) 49 return_picking_type_id = fields.Many2one( 50 'stock.picking.type', 'Operation Type for Returns', 51 check_company=True) 52 show_entire_packs = fields.Boolean('Move Entire Packages', help="If ticked, you will be able to select entire packages to move") 53 warehouse_id = fields.Many2one( 54 'stock.warehouse', 'Warehouse', ondelete='cascade', 55 check_company=True) 56 active = fields.Boolean('Active', default=True) 57 use_create_lots = fields.Boolean( 58 'Create New Lots/Serial Numbers', default=True, 59 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. ") 60 use_existing_lots = fields.Boolean( 61 'Use Existing Lots/Serial Numbers', default=True, 62 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 operation type. This means it will create stock with no lot or not put a restriction on the lot taken. ") 63 show_operations = fields.Boolean( 64 'Show Detailed Operations', default=_default_show_operations, 65 help="If this checkbox is ticked, the pickings lines will represent detailed stock operations. If not, the picking lines will represent an aggregate of detailed stock operations.") 66 show_reserved = fields.Boolean( 67 'Pre-fill Detailed Operations', default=True, 68 help="If this checkbox is ticked, Odoo will automatically pre-fill the detailed " 69 "operations with the corresponding products, locations and lot/serial numbers.") 70 71 count_picking_draft = fields.Integer(compute='_compute_picking_count') 72 count_picking_ready = fields.Integer(compute='_compute_picking_count') 73 count_picking = fields.Integer(compute='_compute_picking_count') 74 count_picking_waiting = fields.Integer(compute='_compute_picking_count') 75 count_picking_late = fields.Integer(compute='_compute_picking_count') 76 count_picking_backorders = fields.Integer(compute='_compute_picking_count') 77 rate_picking_late = fields.Integer(compute='_compute_picking_count') 78 rate_picking_backorders = fields.Integer(compute='_compute_picking_count') 79 barcode = fields.Char('Barcode', copy=False) 80 company_id = fields.Many2one( 81 'res.company', 'Company', required=True, 82 default=lambda s: s.env.company.id, index=True) 83 84 @api.model 85 def create(self, vals): 86 if 'sequence_id' not in vals or not vals['sequence_id']: 87 if vals['warehouse_id']: 88 wh = self.env['stock.warehouse'].browse(vals['warehouse_id']) 89 vals['sequence_id'] = self.env['ir.sequence'].sudo().create({ 90 'name': wh.name + ' ' + _('Sequence') + ' ' + vals['sequence_code'], 91 'prefix': wh.code + '/' + vals['sequence_code'] + '/', 'padding': 5, 92 'company_id': wh.company_id.id, 93 }).id 94 else: 95 vals['sequence_id'] = self.env['ir.sequence'].create({ 96 'name': _('Sequence') + ' ' + vals['sequence_code'], 97 'prefix': vals['sequence_code'], 'padding': 5, 98 'company_id': vals.get('company_id') or self.env.company.id, 99 }).id 100 101 picking_type = super(PickingType, self).create(vals) 102 return picking_type 103 104 def write(self, vals): 105 if 'company_id' in vals: 106 for picking_type in self: 107 if picking_type.company_id.id != vals['company_id']: 108 raise UserError(_("Changing the company of this record is forbidden at this point, you should rather archive it and create a new one.")) 109 if 'sequence_code' in vals: 110 for picking_type in self: 111 if picking_type.warehouse_id: 112 picking_type.sequence_id.write({ 113 'name': picking_type.warehouse_id.name + ' ' + _('Sequence') + ' ' + vals['sequence_code'], 114 'prefix': picking_type.warehouse_id.code + '/' + vals['sequence_code'] + '/', 'padding': 5, 115 'company_id': picking_type.warehouse_id.company_id.id, 116 }) 117 else: 118 picking_type.sequence_id.write({ 119 'name': _('Sequence') + ' ' + vals['sequence_code'], 120 'prefix': vals['sequence_code'], 'padding': 5, 121 'company_id': picking_type.env.company.id, 122 }) 123 return super(PickingType, self).write(vals) 124 125 def _compute_picking_count(self): 126 # TDE TODO count picking can be done using previous two 127 domains = { 128 'count_picking_draft': [('state', '=', 'draft')], 129 'count_picking_waiting': [('state', 'in', ('confirmed', 'waiting'))], 130 'count_picking_ready': [('state', '=', 'assigned')], 131 'count_picking': [('state', 'in', ('assigned', 'waiting', 'confirmed'))], 132 'count_picking_late': [('scheduled_date', '<', time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)), ('state', 'in', ('assigned', 'waiting', 'confirmed'))], 133 'count_picking_backorders': [('backorder_id', '!=', False), ('state', 'in', ('confirmed', 'assigned', 'waiting'))], 134 } 135 for field in domains: 136 data = self.env['stock.picking'].read_group(domains[field] + 137 [('state', 'not in', ('done', 'cancel')), ('picking_type_id', 'in', self.ids)], 138 ['picking_type_id'], ['picking_type_id']) 139 count = { 140 x['picking_type_id'][0]: x['picking_type_id_count'] 141 for x in data if x['picking_type_id'] 142 } 143 for record in self: 144 record[field] = count.get(record.id, 0) 145 for record in self: 146 record.rate_picking_late = record.count_picking and record.count_picking_late * 100 / record.count_picking or 0 147 record.rate_picking_backorders = record.count_picking and record.count_picking_backorders * 100 / record.count_picking or 0 148 149 def name_get(self): 150 """ Display 'Warehouse_name: PickingType_name' """ 151 res = [] 152 for picking_type in self: 153 if picking_type.warehouse_id: 154 name = picking_type.warehouse_id.name + ': ' + picking_type.name 155 else: 156 name = picking_type.name 157 res.append((picking_type.id, name)) 158 return res 159 160 @api.model 161 def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): 162 args = args or [] 163 domain = [] 164 if name: 165 domain = ['|', ('name', operator, name), ('warehouse_id.name', operator, name)] 166 return self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid) 167 168 @api.onchange('code') 169 def _onchange_picking_code(self): 170 warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.company_id.id)], limit=1) 171 stock_location = warehouse.lot_stock_id 172 self.show_operations = self.code != 'incoming' and self.user_has_groups( 173 'stock.group_production_lot,' 174 'stock.group_stock_multi_locations,' 175 'stock.group_tracking_lot' 176 ) 177 if self.code == 'incoming': 178 self.default_location_src_id = self.env.ref('stock.stock_location_suppliers').id 179 self.default_location_dest_id = stock_location.id 180 elif self.code == 'outgoing': 181 self.default_location_src_id = stock_location.id 182 self.default_location_dest_id = self.env.ref('stock.stock_location_customers').id 183 elif self.code == 'internal' and not self.user_has_groups('stock.group_stock_multi_locations'): 184 return { 185 'warning': { 186 'message': _('You need to activate storage locations to be able to do internal operation types.') 187 } 188 } 189 190 @api.onchange('company_id') 191 def _onchange_company_id(self): 192 if self.company_id: 193 warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.company_id.id)], limit=1) 194 self.warehouse_id = warehouse 195 else: 196 self.warehouse_id = False 197 198 @api.onchange('show_operations') 199 def _onchange_show_operations(self): 200 if self.show_operations and self.code != 'incoming': 201 self.show_reserved = True 202 203 def _get_action(self, action_xmlid): 204 action = self.env["ir.actions.actions"]._for_xml_id(action_xmlid) 205 if self: 206 action['display_name'] = self.display_name 207 208 default_immediate_tranfer = True 209 if self.env['ir.config_parameter'].sudo().get_param('stock.no_default_immediate_tranfer'): 210 default_immediate_tranfer = False 211 212 context = { 213 'search_default_picking_type_id': [self.id], 214 'default_picking_type_id': self.id, 215 'default_immediate_transfer': default_immediate_tranfer, 216 'default_company_id': self.company_id.id, 217 } 218 219 action_context = literal_eval(action['context']) 220 context = {**action_context, **context} 221 action['context'] = context 222 return action 223 224 def get_action_picking_tree_late(self): 225 return self._get_action('stock.action_picking_tree_late') 226 227 def get_action_picking_tree_backorder(self): 228 return self._get_action('stock.action_picking_tree_backorder') 229 230 def get_action_picking_tree_waiting(self): 231 return self._get_action('stock.action_picking_tree_waiting') 232 233 def get_action_picking_tree_ready(self): 234 return self._get_action('stock.action_picking_tree_ready') 235 236 def get_stock_picking_action_picking_type(self): 237 return self._get_action('stock.stock_picking_action_picking_type') 238 239 240class Picking(models.Model): 241 _name = "stock.picking" 242 _inherit = ['mail.thread', 'mail.activity.mixin'] 243 _description = "Transfer" 244 _order = "priority desc, scheduled_date asc, id desc" 245 246 name = fields.Char( 247 'Reference', default='/', 248 copy=False, index=True, readonly=True) 249 origin = fields.Char( 250 'Source Document', index=True, 251 states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, 252 help="Reference of the document") 253 note = fields.Text('Notes') 254 backorder_id = fields.Many2one( 255 'stock.picking', 'Back Order of', 256 copy=False, index=True, readonly=True, 257 check_company=True, 258 help="If this shipment was split, then this field links to the shipment which contains the already processed part.") 259 backorder_ids = fields.One2many('stock.picking', 'backorder_id', 'Back Orders') 260 move_type = fields.Selection([ 261 ('direct', 'As soon as possible'), ('one', 'When all products are ready')], 'Shipping Policy', 262 default='direct', required=True, 263 states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, 264 help="It specifies goods to be deliver partially or all at once") 265 state = fields.Selection([ 266 ('draft', 'Draft'), 267 ('waiting', 'Waiting Another Operation'), 268 ('confirmed', 'Waiting'), 269 ('assigned', 'Ready'), 270 ('done', 'Done'), 271 ('cancel', 'Cancelled'), 272 ], string='Status', compute='_compute_state', 273 copy=False, index=True, readonly=True, store=True, tracking=True, 274 help=" * Draft: The transfer is not confirmed yet. Reservation doesn't apply.\n" 275 " * Waiting another operation: This transfer is waiting for another operation before being ready.\n" 276 " * Waiting: The transfer is waiting for the availability of some products.\n(a) The shipping policy is \"As soon as possible\": no product could be reserved.\n(b) The shipping policy is \"When all products are ready\": not all the products could be reserved.\n" 277 " * Ready: The transfer is ready to be processed.\n(a) The shipping policy is \"As soon as possible\": at least one product has been reserved.\n(b) The shipping policy is \"When all products are ready\": all product have been reserved.\n" 278 " * Done: The transfer has been processed.\n" 279 " * Cancelled: The transfer has been cancelled.") 280 group_id = fields.Many2one( 281 'procurement.group', 'Procurement Group', 282 readonly=True, related='move_lines.group_id', store=True) 283 priority = fields.Selection( 284 PROCUREMENT_PRIORITIES, string='Priority', default='0', index=True, 285 help="Products will be reserved first for the transfers with the highest priorities.") 286 scheduled_date = fields.Datetime( 287 'Scheduled Date', compute='_compute_scheduled_date', inverse='_set_scheduled_date', store=True, 288 index=True, default=fields.Datetime.now, tracking=True, 289 states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, 290 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.") 291 date_deadline = fields.Datetime( 292 "Deadline", compute='_compute_date_deadline', store=True, 293 help="Date Promise to the customer on the top level document (SO/PO)") 294 has_deadline_issue = fields.Boolean( 295 "Is late", compute='_compute_has_deadline_issue', store=True, default=False, 296 help="Is late or will be late depending on the deadline and scheduled date") 297 date = fields.Datetime( 298 'Creation Date', 299 default=fields.Datetime.now, index=True, tracking=True, 300 states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, 301 help="Creation Date, usually the time of the order") 302 date_done = fields.Datetime('Date of Transfer', copy=False, readonly=True, help="Date at which the transfer has been processed or cancelled.") 303 delay_alert_date = fields.Datetime('Delay Alert Date', compute='_compute_delay_alert_date', search='_search_delay_alert_date') 304 json_popover = fields.Char('JSON data for the popover widget', compute='_compute_json_popover') 305 location_id = fields.Many2one( 306 'stock.location', "Source Location", 307 default=lambda self: self.env['stock.picking.type'].browse(self._context.get('default_picking_type_id')).default_location_src_id, 308 check_company=True, readonly=True, required=True, 309 states={'draft': [('readonly', False)]}) 310 location_dest_id = fields.Many2one( 311 'stock.location', "Destination Location", 312 default=lambda self: self.env['stock.picking.type'].browse(self._context.get('default_picking_type_id')).default_location_dest_id, 313 check_company=True, readonly=True, required=True, 314 states={'draft': [('readonly', False)]}) 315 move_lines = fields.One2many('stock.move', 'picking_id', string="Stock Moves", copy=True) 316 move_ids_without_package = fields.One2many('stock.move', 'picking_id', string="Stock moves not in package", compute='_compute_move_without_package', inverse='_set_move_without_package') 317 has_scrap_move = fields.Boolean( 318 'Has Scrap Moves', compute='_has_scrap_move') 319 picking_type_id = fields.Many2one( 320 'stock.picking.type', 'Operation Type', 321 required=True, readonly=True, 322 states={'draft': [('readonly', False)]}) 323 picking_type_code = fields.Selection( 324 related='picking_type_id.code', 325 readonly=True) 326 picking_type_entire_packs = fields.Boolean(related='picking_type_id.show_entire_packs', 327 readonly=True) 328 hide_picking_type = fields.Boolean(compute='_compute_hide_pickign_type') 329 partner_id = fields.Many2one( 330 'res.partner', 'Contact', 331 check_company=True, 332 states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) 333 company_id = fields.Many2one( 334 'res.company', string='Company', related='picking_type_id.company_id', 335 readonly=True, store=True, index=True) 336 user_id = fields.Many2one( 337 'res.users', 'Responsible', tracking=True, 338 domain=lambda self: [('groups_id', 'in', self.env.ref('stock.group_stock_user').id)], 339 states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, 340 default=lambda self: self.env.user) 341 move_line_ids = fields.One2many('stock.move.line', 'picking_id', 'Operations') 342 move_line_ids_without_package = fields.One2many('stock.move.line', 'picking_id', 'Operations without package', domain=['|',('package_level_id', '=', False), ('picking_type_entire_packs', '=', False)]) 343 move_line_nosuggest_ids = fields.One2many('stock.move.line', 'picking_id', domain=[('product_qty', '=', 0.0)]) 344 move_line_exist = fields.Boolean( 345 'Has Pack Operations', compute='_compute_move_line_exist', 346 help='Check the existence of pack operation on the picking') 347 has_packages = fields.Boolean( 348 'Has Packages', compute='_compute_has_packages', 349 help='Check the existence of destination packages on move lines') 350 show_check_availability = fields.Boolean( 351 compute='_compute_show_check_availability', 352 help='Technical field used to compute whether the button "Check Availability" should be displayed.') 353 show_mark_as_todo = fields.Boolean( 354 compute='_compute_show_mark_as_todo', 355 help='Technical field used to compute whether the button "Mark as Todo" should be displayed.') 356 show_validate = fields.Boolean( 357 compute='_compute_show_validate', 358 help='Technical field used to decide whether the button "Validate" should be displayed.') 359 use_create_lots = fields.Boolean(related='picking_type_id.use_create_lots') 360 owner_id = fields.Many2one( 361 'res.partner', 'Assign Owner', 362 states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, 363 check_company=True, 364 help="When validating the transfer, the products will be assigned to this owner.") 365 printed = fields.Boolean('Printed', copy=False) 366 signature = fields.Image('Signature', help='Signature', copy=False, attachment=True) 367 is_locked = fields.Boolean(default=True, help='When the picking is not done this allows changing the ' 368 'initial demand. When the picking is done this allows ' 369 'changing the done quantities.') 370 # Used to search on pickings 371 product_id = fields.Many2one('product.product', 'Product', related='move_lines.product_id', readonly=True) 372 show_operations = fields.Boolean(compute='_compute_show_operations') 373 show_reserved = fields.Boolean(related='picking_type_id.show_reserved') 374 show_lots_text = fields.Boolean(compute='_compute_show_lots_text') 375 has_tracking = fields.Boolean(compute='_compute_has_tracking') 376 immediate_transfer = fields.Boolean(default=False) 377 package_level_ids = fields.One2many('stock.package_level', 'picking_id') 378 package_level_ids_details = fields.One2many('stock.package_level', 'picking_id') 379 products_availability = fields.Char( 380 string="Product Availability", compute='_compute_products_availability') 381 products_availability_state = fields.Selection([ 382 ('available', 'Available'), 383 ('expected', 'Expected'), 384 ('late', 'Late')], compute='_compute_products_availability') 385 386 _sql_constraints = [ 387 ('name_uniq', 'unique(name, company_id)', 'Reference must be unique per company!'), 388 ] 389 390 def _compute_has_tracking(self): 391 for picking in self: 392 picking.has_tracking = any(m.has_tracking != 'none' for m in picking.move_lines) 393 394 @api.depends('date_deadline', 'scheduled_date') 395 def _compute_has_deadline_issue(self): 396 for picking in self: 397 picking.has_deadline_issue = picking.date_deadline and picking.date_deadline < picking.scheduled_date or False 398 399 def _compute_hide_pickign_type(self): 400 self.hide_picking_type = self.env.context.get('default_picking_type_id', False) 401 402 @api.depends('move_lines.delay_alert_date') 403 def _compute_delay_alert_date(self): 404 delay_alert_date_data = self.env['stock.move'].read_group([('id', 'in', self.move_lines.ids), ('delay_alert_date', '!=', False)], ['delay_alert_date:max'], 'picking_id') 405 delay_alert_date_data = {data['picking_id'][0]: data['delay_alert_date'] for data in delay_alert_date_data} 406 for picking in self: 407 picking.delay_alert_date = delay_alert_date_data.get(picking.id, False) 408 409 @api.depends('move_lines', 'state', 'picking_type_code', 'move_lines.forecast_availability', 'move_lines.forecast_expected_date') 410 def _compute_products_availability(self): 411 self.products_availability = False 412 self.products_availability_state = 'available' 413 pickings = self.filtered(lambda picking: picking.state not in ['cancel', 'draft', 'done'] and picking.picking_type_code == 'outgoing') 414 pickings.products_availability = _('Available') 415 for picking in pickings: 416 forecast_date = max(picking.move_lines.filtered('forecast_expected_date').mapped('forecast_expected_date'), default=False) 417 if any(float_compare(move.forecast_availability, move.product_qty, precision_rounding=move.product_id.uom_id.rounding) == -1 for move in picking.move_lines): 418 picking.products_availability = _('Not Available') 419 picking.products_availability_state = 'late' 420 elif forecast_date: 421 picking.products_availability = _('Exp %s', format_date(self.env, forecast_date)) 422 picking.products_availability_state = 'late' if picking.date_deadline and picking.date_deadline < forecast_date else 'expected' 423 424 @api.depends('picking_type_id.show_operations') 425 def _compute_show_operations(self): 426 for picking in self: 427 if self.env.context.get('force_detailed_view'): 428 picking.show_operations = True 429 continue 430 if picking.picking_type_id.show_operations: 431 if (picking.state == 'draft' and picking.immediate_transfer) or picking.state != 'draft': 432 picking.show_operations = True 433 else: 434 picking.show_operations = False 435 else: 436 picking.show_operations = False 437 438 @api.depends('move_line_ids', 'picking_type_id.use_create_lots', 'picking_type_id.use_existing_lots', 'state') 439 def _compute_show_lots_text(self): 440 group_production_lot_enabled = self.user_has_groups('stock.group_production_lot') 441 for picking in self: 442 if not picking.move_line_ids and not picking.picking_type_id.use_create_lots: 443 picking.show_lots_text = False 444 elif group_production_lot_enabled and picking.picking_type_id.use_create_lots \ 445 and not picking.picking_type_id.use_existing_lots and picking.state != 'done': 446 picking.show_lots_text = True 447 else: 448 picking.show_lots_text = False 449 450 def _compute_json_popover(self): 451 for picking in self: 452 if picking.state in ('done', 'cancel') or not picking.delay_alert_date: 453 picking.json_popover = False 454 continue 455 picking.json_popover = json.dumps({ 456 'popoverTemplate': 'stock.PopoverStockRescheduling', 457 'delay_alert_date': format_datetime(self.env, picking.delay_alert_date, dt_format=False) if picking.delay_alert_date else False, 458 'late_elements': [{ 459 'id': late_move.id, 460 'name': late_move.display_name, 461 'model': late_move._name, 462 } for late_move in picking.move_lines.filtered(lambda m: m.delay_alert_date).move_orig_ids._delay_alert_get_documents() 463 ] 464 }) 465 466 @api.depends('move_type', 'immediate_transfer', 'move_lines.state', 'move_lines.picking_id') 467 def _compute_state(self): 468 ''' State of a picking depends on the state of its related stock.move 469 - Draft: only used for "planned pickings" 470 - Waiting: if the picking is not ready to be sent so if 471 - (a) no quantity could be reserved at all or if 472 - (b) some quantities could be reserved and the shipping policy is "deliver all at once" 473 - Waiting another move: if the picking is waiting for another move 474 - Ready: if the picking is ready to be sent so if: 475 - (a) all quantities are reserved or if 476 - (b) some quantities could be reserved and the shipping policy is "as soon as possible" 477 - Done: if the picking is done. 478 - Cancelled: if the picking is cancelled 479 ''' 480 picking_moves_state_map = defaultdict(dict) 481 picking_move_lines = defaultdict(set) 482 for move in self.env['stock.move'].search([('picking_id', 'in', self.ids)]): 483 picking_id = move.picking_id 484 move_state = move.state 485 picking_moves_state_map[picking_id.id].update({ 486 'any_draft': picking_moves_state_map[picking_id.id].get('any_draft', False) or move_state == 'draft', 487 'all_cancel': picking_moves_state_map[picking_id.id].get('all_cancel', True) and move_state == 'cancel', 488 'all_cancel_done': picking_moves_state_map[picking_id.id].get('all_cancel_done', True) and move_state in ('cancel', 'done'), 489 }) 490 picking_move_lines[picking_id.id].add(move.id) 491 for picking in self: 492 if not picking_moves_state_map[picking.id]: 493 picking.state = 'draft' 494 elif picking_moves_state_map[picking.id]['any_draft']: 495 picking.state = 'draft' 496 elif picking_moves_state_map[picking.id]['all_cancel']: 497 picking.state = 'cancel' 498 elif picking_moves_state_map[picking.id]['all_cancel_done']: 499 picking.state = 'done' 500 else: 501 relevant_move_state = self.env['stock.move'].browse(picking_move_lines[picking.id])._get_relevant_state_among_moves() 502 if picking.immediate_transfer and relevant_move_state not in ('draft', 'cancel', 'done'): 503 picking.state = 'assigned' 504 elif relevant_move_state == 'partially_available': 505 picking.state = 'assigned' 506 else: 507 picking.state = relevant_move_state 508 509 @api.depends('move_lines.state', 'move_lines.date', 'move_type') 510 def _compute_scheduled_date(self): 511 for picking in self: 512 moves_dates = picking.move_lines.filtered(lambda move: move.state not in ('done', 'cancel')).mapped('date') 513 if picking.move_type == 'direct': 514 picking.scheduled_date = min(moves_dates, default=picking.scheduled_date or fields.Datetime.now()) 515 else: 516 picking.scheduled_date = max(moves_dates, default=picking.scheduled_date or fields.Datetime.now()) 517 518 @api.depends('move_lines.date_deadline', 'move_type') 519 def _compute_date_deadline(self): 520 for picking in self: 521 if picking.move_type == 'direct': 522 picking.date_deadline = min(picking.move_lines.filtered('date_deadline').mapped('date_deadline'), default=False) 523 else: 524 picking.date_deadline = max(picking.move_lines.filtered('date_deadline').mapped('date_deadline'), default=False) 525 526 def _set_scheduled_date(self): 527 for picking in self: 528 if picking.state in ('done', 'cancel'): 529 raise UserError(_("You cannot change the Scheduled Date on a done or cancelled transfer.")) 530 picking.move_lines.write({'date': picking.scheduled_date}) 531 532 def _has_scrap_move(self): 533 for picking in self: 534 # TDE FIXME: better implementation 535 picking.has_scrap_move = bool(self.env['stock.move'].search_count([('picking_id', '=', picking.id), ('scrapped', '=', True)])) 536 537 def _compute_move_line_exist(self): 538 for picking in self: 539 picking.move_line_exist = bool(picking.move_line_ids) 540 541 def _compute_has_packages(self): 542 domain = [('picking_id', 'in', self.ids), ('result_package_id', '!=', False)] 543 cnt_by_picking = self.env['stock.move.line'].read_group(domain, ['picking_id'], ['picking_id']) 544 cnt_by_picking = {d['picking_id'][0]: d['picking_id_count'] for d in cnt_by_picking} 545 for picking in self: 546 picking.has_packages = bool(cnt_by_picking.get(picking.id, False)) 547 548 @api.depends('immediate_transfer', 'state') 549 def _compute_show_check_availability(self): 550 """ According to `picking.show_check_availability`, the "check availability" button will be 551 displayed in the form view of a picking. 552 """ 553 for picking in self: 554 if picking.immediate_transfer or picking.state not in ('confirmed', 'waiting', 'assigned'): 555 picking.show_check_availability = False 556 continue 557 picking.show_check_availability = any( 558 move.state in ('waiting', 'confirmed', 'partially_available') and 559 float_compare(move.product_uom_qty, 0, precision_rounding=move.product_uom.rounding) 560 for move in picking.move_lines 561 ) 562 563 @api.depends('state', 'move_lines') 564 def _compute_show_mark_as_todo(self): 565 for picking in self: 566 if not picking.move_lines and not picking.package_level_ids: 567 picking.show_mark_as_todo = False 568 elif not picking.immediate_transfer and picking.state == 'draft': 569 picking.show_mark_as_todo = True 570 elif picking.state != 'draft' or not picking.id: 571 picking.show_mark_as_todo = False 572 else: 573 picking.show_mark_as_todo = True 574 575 @api.depends('state') 576 def _compute_show_validate(self): 577 for picking in self: 578 if not (picking.immediate_transfer) and picking.state == 'draft': 579 picking.show_validate = False 580 elif picking.state not in ('draft', 'waiting', 'confirmed', 'assigned'): 581 picking.show_validate = False 582 else: 583 picking.show_validate = True 584 585 @api.model 586 def _search_delay_alert_date(self, operator, value): 587 late_stock_moves = self.env['stock.move'].search([('delay_alert_date', operator, value)]) 588 return [('move_lines', 'in', late_stock_moves.ids)] 589 590 @api.onchange('partner_id') 591 def onchange_partner_id(self): 592 for picking in self: 593 picking_id = isinstance(picking.id, int) and picking.id or getattr(picking, '_origin', False) and picking._origin.id 594 if picking_id: 595 moves = self.env['stock.move'].search([('picking_id', '=', picking_id)]) 596 for move in moves: 597 move.write({'partner_id': picking.partner_id.id}) 598 599 @api.onchange('picking_type_id', 'partner_id') 600 def onchange_picking_type(self): 601 if self.picking_type_id and self.state == 'draft': 602 self = self.with_company(self.company_id) 603 if self.picking_type_id.default_location_src_id: 604 location_id = self.picking_type_id.default_location_src_id.id 605 elif self.partner_id: 606 location_id = self.partner_id.property_stock_supplier.id 607 else: 608 customerloc, location_id = self.env['stock.warehouse']._get_partner_locations() 609 610 if self.picking_type_id.default_location_dest_id: 611 location_dest_id = self.picking_type_id.default_location_dest_id.id 612 elif self.partner_id: 613 location_dest_id = self.partner_id.property_stock_customer.id 614 else: 615 location_dest_id, supplierloc = self.env['stock.warehouse']._get_partner_locations() 616 617 self.location_id = location_id 618 self.location_dest_id = location_dest_id 619 (self.move_lines | self.move_ids_without_package).update({ 620 "picking_type_id": self.picking_type_id, 621 "company_id": self.company_id, 622 }) 623 624 if self.partner_id and self.partner_id.picking_warn: 625 if self.partner_id.picking_warn == 'no-message' and self.partner_id.parent_id: 626 partner = self.partner_id.parent_id 627 elif self.partner_id.picking_warn not in ('no-message', 'block') and self.partner_id.parent_id.picking_warn == 'block': 628 partner = self.partner_id.parent_id 629 else: 630 partner = self.partner_id 631 if partner.picking_warn != 'no-message': 632 if partner.picking_warn == 'block': 633 self.partner_id = False 634 return {'warning': { 635 'title': ("Warning for %s") % partner.name, 636 'message': partner.picking_warn_msg 637 }} 638 639 @api.model 640 def create(self, vals): 641 defaults = self.default_get(['name', 'picking_type_id']) 642 picking_type = self.env['stock.picking.type'].browse(vals.get('picking_type_id', defaults.get('picking_type_id'))) 643 if vals.get('name', '/') == '/' and defaults.get('name', '/') == '/' and vals.get('picking_type_id', defaults.get('picking_type_id')): 644 if picking_type.sequence_id: 645 vals['name'] = picking_type.sequence_id.next_by_id() 646 647 # As the on_change in one2many list is WIP, we will overwrite the locations on the stock moves here 648 # As it is a create the format will be a list of (0, 0, dict) 649 moves = vals.get('move_lines', []) + vals.get('move_ids_without_package', []) 650 if moves and vals.get('location_id') and vals.get('location_dest_id'): 651 for move in moves: 652 if len(move) == 3 and move[0] == 0: 653 move[2]['location_id'] = vals['location_id'] 654 move[2]['location_dest_id'] = vals['location_dest_id'] 655 # When creating a new picking, a move can have no `company_id` (create before 656 # picking type was defined) or a different `company_id` (the picking type was 657 # changed for an another company picking type after the move was created). 658 # So, we define the `company_id` in one of these cases. 659 picking_type = self.env['stock.picking.type'].browse(vals['picking_type_id']) 660 if 'picking_type_id' not in move[2] or move[2]['picking_type_id'] != picking_type.id: 661 move[2]['picking_type_id'] = picking_type.id 662 move[2]['company_id'] = picking_type.company_id.id 663 # make sure to write `schedule_date` *after* the `stock.move` creation in 664 # order to get a determinist execution of `_set_scheduled_date` 665 scheduled_date = vals.pop('scheduled_date', False) 666 res = super(Picking, self).create(vals) 667 if scheduled_date: 668 res.with_context(mail_notrack=True).write({'scheduled_date': scheduled_date}) 669 res._autoconfirm_picking() 670 671 # set partner as follower 672 if vals.get('partner_id'): 673 for picking in res.filtered(lambda p: p.location_id.usage == 'supplier' or p.location_dest_id.usage == 'customer'): 674 picking.message_subscribe([vals.get('partner_id')]) 675 if vals.get('picking_type_id'): 676 for move in res.move_lines: 677 if not move.description_picking: 678 move.description_picking = move.product_id.with_context(lang=move._get_lang())._get_description(move.picking_id.picking_type_id) 679 680 return res 681 682 def write(self, vals): 683 if vals.get('picking_type_id') and any(picking.state != 'draft' for picking in self): 684 raise UserError(_("Changing the operation type of this record is forbidden at this point.")) 685 # set partner as a follower and unfollow old partner 686 if vals.get('partner_id'): 687 for picking in self: 688 if picking.location_id.usage == 'supplier' or picking.location_dest_id.usage == 'customer': 689 if picking.partner_id: 690 picking.message_unsubscribe(picking.partner_id.ids) 691 picking.message_subscribe([vals.get('partner_id')]) 692 res = super(Picking, self).write(vals) 693 if vals.get('signature'): 694 for picking in self: 695 picking._attach_sign() 696 # Change locations of moves if those of the picking change 697 after_vals = {} 698 if vals.get('location_id'): 699 after_vals['location_id'] = vals['location_id'] 700 if vals.get('location_dest_id'): 701 after_vals['location_dest_id'] = vals['location_dest_id'] 702 if after_vals: 703 self.mapped('move_lines').filtered(lambda move: not move.scrapped).write(after_vals) 704 if vals.get('move_lines'): 705 self._autoconfirm_picking() 706 707 return res 708 709 def unlink(self): 710 self.mapped('move_lines')._action_cancel() 711 self.with_context(prefetch_fields=False).mapped('move_lines').unlink() # Checks if moves are not done 712 return super(Picking, self).unlink() 713 714 def action_assign_partner(self): 715 for picking in self: 716 picking.move_lines.write({'partner_id': picking.partner_id.id}) 717 718 def do_print_picking(self): 719 self.write({'printed': True}) 720 return self.env.ref('stock.action_report_picking').report_action(self) 721 722 def action_confirm(self): 723 self._check_company() 724 self.mapped('package_level_ids').filtered(lambda pl: pl.state == 'draft' and not pl.move_ids)._generate_moves() 725 # call `_action_confirm` on every draft move 726 self.mapped('move_lines')\ 727 .filtered(lambda move: move.state == 'draft')\ 728 ._action_confirm() 729 730 # run scheduler for moves forecasted to not have enough in stock 731 self.mapped('move_lines').filtered(lambda move: move.state not in ('draft', 'cancel', 'done'))._trigger_scheduler() 732 return True 733 734 def action_assign(self): 735 """ Check availability of picking moves. 736 This has the effect of changing the state and reserve quants on available moves, and may 737 also impact the state of the picking as it is computed based on move's states. 738 @return: True 739 """ 740 self.filtered(lambda picking: picking.state == 'draft').action_confirm() 741 moves = self.mapped('move_lines').filtered(lambda move: move.state not in ('draft', 'cancel', 'done')) 742 if not moves: 743 raise UserError(_('Nothing to check the availability for.')) 744 # If a package level is done when confirmed its location can be different than where it will be reserved. 745 # So we remove the move lines created when confirmed to set quantity done to the new reserved ones. 746 package_level_done = self.mapped('package_level_ids').filtered(lambda pl: pl.is_done and pl.state == 'confirmed') 747 package_level_done.write({'is_done': False}) 748 moves._action_assign() 749 package_level_done.write({'is_done': True}) 750 751 return True 752 753 def action_cancel(self): 754 self.mapped('move_lines')._action_cancel() 755 self.write({'is_locked': True}) 756 return True 757 758 def _action_done(self): 759 """Call `_action_done` on the `stock.move` of the `stock.picking` in `self`. 760 This method makes sure every `stock.move.line` is linked to a `stock.move` by either 761 linking them to an existing one or a newly created one. 762 763 If the context key `cancel_backorder` is present, backorders won't be created. 764 765 :return: True 766 :rtype: bool 767 """ 768 self._check_company() 769 770 todo_moves = self.mapped('move_lines').filtered(lambda self: self.state in ['draft', 'waiting', 'partially_available', 'assigned', 'confirmed']) 771 for picking in self: 772 if picking.owner_id: 773 picking.move_lines.write({'restrict_partner_id': picking.owner_id.id}) 774 picking.move_line_ids.write({'owner_id': picking.owner_id.id}) 775 todo_moves._action_done(cancel_backorder=self.env.context.get('cancel_backorder')) 776 self.write({'date_done': fields.Datetime.now(), 'priority': '0'}) 777 778 # if incoming moves make other confirmed/partially_available moves available, assign them 779 done_incoming_moves = self.filtered(lambda p: p.picking_type_id.code == 'incoming').move_lines.filtered(lambda m: m.state == 'done') 780 done_incoming_moves._trigger_assign() 781 782 self._send_confirmation_email() 783 return True 784 785 def _send_confirmation_email(self): 786 for stock_pick in self.filtered(lambda p: p.company_id.stock_move_email_validation and p.picking_type_id.code == 'outgoing'): 787 delivery_template_id = stock_pick.company_id.stock_mail_confirmation_template_id.id 788 stock_pick.with_context(force_send=True).message_post_with_template(delivery_template_id, email_layout_xmlid='mail.mail_notification_light') 789 790 @api.depends('state', 'move_lines', 'move_lines.state', 'move_lines.package_level_id', 'move_lines.move_line_ids.package_level_id') 791 def _compute_move_without_package(self): 792 for picking in self: 793 picking.move_ids_without_package = picking._get_move_ids_without_package() 794 795 def _set_move_without_package(self): 796 new_mwp = self[0].move_ids_without_package 797 for picking in self: 798 old_mwp = picking._get_move_ids_without_package() 799 picking.move_lines = (picking.move_lines - old_mwp) | new_mwp 800 moves_to_unlink = old_mwp - new_mwp 801 if moves_to_unlink: 802 moves_to_unlink.unlink() 803 804 def _get_move_ids_without_package(self): 805 self.ensure_one() 806 move_ids_without_package = self.env['stock.move'] 807 if not self.picking_type_entire_packs: 808 move_ids_without_package = self.move_lines 809 else: 810 for move in self.move_lines: 811 if not move.package_level_id: 812 if move.state == 'assigned' and move.picking_id and not move.picking_id.immediate_transfer or move.state == 'done': 813 if any(not ml.package_level_id for ml in move.move_line_ids): 814 move_ids_without_package |= move 815 else: 816 move_ids_without_package |= move 817 return move_ids_without_package.filtered(lambda move: not move.scrap_ids) 818 819 def _check_move_lines_map_quant_package(self, package): 820 """ This method checks that all product of the package (quant) are well present in the move_line_ids of the picking. """ 821 all_in = True 822 pack_move_lines = self.move_line_ids.filtered(lambda ml: ml.package_id == package) 823 keys = ['product_id', 'lot_id'] 824 keys_ids = ["{}.id".format(fname) for fname in keys] 825 precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure') 826 827 grouped_quants = {} 828 for k, g in groupby(sorted(package.quant_ids, key=attrgetter(*keys_ids)), key=itemgetter(*keys)): 829 grouped_quants[k] = sum(self.env['stock.quant'].concat(*list(g)).mapped('quantity')) 830 831 grouped_ops = {} 832 for k, g in groupby(sorted(pack_move_lines, key=attrgetter(*keys_ids)), key=itemgetter(*keys)): 833 grouped_ops[k] = sum(self.env['stock.move.line'].concat(*list(g)).mapped('product_qty')) 834 if any(not float_is_zero(grouped_quants.get(key, 0) - grouped_ops.get(key, 0), precision_digits=precision_digits) for key in grouped_quants) \ 835 or any(not float_is_zero(grouped_ops.get(key, 0) - grouped_quants.get(key, 0), precision_digits=precision_digits) for key in grouped_ops): 836 all_in = False 837 return all_in 838 839 def _get_entire_pack_location_dest(self, move_line_ids): 840 location_dest_ids = move_line_ids.mapped('location_dest_id') 841 if len(location_dest_ids) > 1: 842 return False 843 return location_dest_ids.id 844 845 def _check_entire_pack(self): 846 """ This function check if entire packs are moved in the picking""" 847 for picking in self: 848 origin_packages = picking.move_line_ids.mapped("package_id") 849 for pack in origin_packages: 850 if picking._check_move_lines_map_quant_package(pack): 851 package_level_ids = picking.package_level_ids.filtered(lambda pl: pl.package_id == pack) 852 move_lines_to_pack = picking.move_line_ids.filtered(lambda ml: ml.package_id == pack and not ml.result_package_id) 853 if not package_level_ids: 854 self.env['stock.package_level'].create({ 855 'picking_id': picking.id, 856 'package_id': pack.id, 857 'location_id': pack.location_id.id, 858 'location_dest_id': self._get_entire_pack_location_dest(move_lines_to_pack) or picking.location_dest_id.id, 859 'move_line_ids': [(6, 0, move_lines_to_pack.ids)], 860 'company_id': picking.company_id.id, 861 }) 862 # TODO: in master, move package field in `stock` and clean code. 863 if pack._allowed_to_move_between_transfers(): 864 move_lines_to_pack.write({ 865 'result_package_id': pack.id, 866 }) 867 else: 868 move_lines_in_package_level = move_lines_to_pack.filtered(lambda ml: ml.move_id.package_level_id) 869 move_lines_without_package_level = move_lines_to_pack - move_lines_in_package_level 870 for ml in move_lines_in_package_level: 871 ml.write({ 872 'result_package_id': pack.id, 873 'package_level_id': ml.move_id.package_level_id.id, 874 }) 875 move_lines_without_package_level.write({ 876 'result_package_id': pack.id, 877 'package_level_id': package_level_ids[0].id, 878 }) 879 for pl in package_level_ids: 880 pl.location_dest_id = self._get_entire_pack_location_dest(pl.move_line_ids) or picking.location_dest_id.id 881 882 def do_unreserve(self): 883 self.move_lines._do_unreserve() 884 self.package_level_ids.filtered(lambda p: not p.move_ids).unlink() 885 886 def button_validate(self): 887 # Clean-up the context key at validation to avoid forcing the creation of immediate 888 # transfers. 889 ctx = dict(self.env.context) 890 ctx.pop('default_immediate_transfer', None) 891 self = self.with_context(ctx) 892 893 # Sanity checks. 894 pickings_without_moves = self.browse() 895 pickings_without_quantities = self.browse() 896 pickings_without_lots = self.browse() 897 products_without_lots = self.env['product.product'] 898 for picking in self: 899 if not picking.move_lines and not picking.move_line_ids: 900 pickings_without_moves |= picking 901 902 picking.message_subscribe([self.env.user.partner_id.id]) 903 picking_type = picking.picking_type_id 904 precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure') 905 no_quantities_done = all(float_is_zero(move_line.qty_done, precision_digits=precision_digits) for move_line in picking.move_line_ids.filtered(lambda m: m.state not in ('done', 'cancel'))) 906 no_reserved_quantities = all(float_is_zero(move_line.product_qty, precision_rounding=move_line.product_uom_id.rounding) for move_line in picking.move_line_ids) 907 if no_reserved_quantities and no_quantities_done: 908 pickings_without_quantities |= picking 909 910 if picking_type.use_create_lots or picking_type.use_existing_lots: 911 lines_to_check = picking.move_line_ids 912 if not no_quantities_done: 913 lines_to_check = lines_to_check.filtered(lambda line: float_compare(line.qty_done, 0, precision_rounding=line.product_uom_id.rounding)) 914 for line in lines_to_check: 915 product = line.product_id 916 if product and product.tracking != 'none': 917 if not line.lot_name and not line.lot_id: 918 pickings_without_lots |= picking 919 products_without_lots |= product 920 921 if not self._should_show_transfers(): 922 if pickings_without_moves: 923 raise UserError(_('Please add some items to move.')) 924 if pickings_without_quantities: 925 raise UserError(self._get_without_quantities_error_message()) 926 if pickings_without_lots: 927 raise UserError(_('You need to supply a Lot/Serial number for products %s.') % ', '.join(products_without_lots.mapped('display_name'))) 928 else: 929 message = "" 930 if pickings_without_moves: 931 message += _('Transfers %s: Please add some items to move.') % ', '.join(pickings_without_moves.mapped('name')) 932 if pickings_without_quantities: 933 message += _('\n\nTransfers %s: You cannot validate these transfers if no quantities are reserved nor done. To force these transfers, switch in edit more and encode the done quantities.') % ', '.join(pickings_without_quantities.mapped('name')) 934 if pickings_without_lots: 935 message += _('\n\nTransfers %s: You need to supply a Lot/Serial number for products %s.') % (', '.join(pickings_without_lots.mapped('name')), ', '.join(products_without_lots.mapped('display_name'))) 936 if message: 937 raise UserError(message.lstrip()) 938 939 # Run the pre-validation wizards. Processing a pre-validation wizard should work on the 940 # moves and/or the context and never call `_action_done`. 941 if not self.env.context.get('button_validate_picking_ids'): 942 self = self.with_context(button_validate_picking_ids=self.ids) 943 res = self._pre_action_done_hook() 944 if res is not True: 945 return res 946 947 # Call `_action_done`. 948 if self.env.context.get('picking_ids_not_to_backorder'): 949 pickings_not_to_backorder = self.browse(self.env.context['picking_ids_not_to_backorder']) 950 pickings_to_backorder = self - pickings_not_to_backorder 951 else: 952 pickings_not_to_backorder = self.env['stock.picking'] 953 pickings_to_backorder = self 954 pickings_not_to_backorder.with_context(cancel_backorder=True)._action_done() 955 pickings_to_backorder.with_context(cancel_backorder=False)._action_done() 956 return True 957 958 def _pre_action_done_hook(self): 959 if not self.env.context.get('skip_immediate'): 960 pickings_to_immediate = self._check_immediate() 961 if pickings_to_immediate: 962 return pickings_to_immediate._action_generate_immediate_wizard(show_transfers=self._should_show_transfers()) 963 964 if not self.env.context.get('skip_backorder'): 965 pickings_to_backorder = self._check_backorder() 966 if pickings_to_backorder: 967 return pickings_to_backorder._action_generate_backorder_wizard(show_transfers=self._should_show_transfers()) 968 return True 969 970 def _should_show_transfers(self): 971 """Whether the different transfers should be displayed on the pre action done wizards.""" 972 return len(self) > 1 973 974 def _get_without_quantities_error_message(self): 975 """ Returns the error message raised in validation if no quantities are reserved or done. 976 The purpose of this method is to be overridden in case we want to adapt this message. 977 978 :return: Translated error message 979 :rtype: str 980 """ 981 return _( 982 'You cannot validate a transfer if no quantities are reserved nor done. ' 983 'To force the transfer, switch in edit mode and encode the done quantities.' 984 ) 985 986 def _action_generate_backorder_wizard(self, show_transfers=False): 987 view = self.env.ref('stock.view_backorder_confirmation') 988 return { 989 'name': _('Create Backorder?'), 990 'type': 'ir.actions.act_window', 991 'view_mode': 'form', 992 'res_model': 'stock.backorder.confirmation', 993 'views': [(view.id, 'form')], 994 'view_id': view.id, 995 'target': 'new', 996 'context': dict(self.env.context, default_show_transfers=show_transfers, default_pick_ids=[(4, p.id) for p in self]), 997 } 998 999 def _action_generate_immediate_wizard(self, show_transfers=False): 1000 view = self.env.ref('stock.view_immediate_transfer') 1001 return { 1002 'name': _('Immediate Transfer?'), 1003 'type': 'ir.actions.act_window', 1004 'view_mode': 'form', 1005 'res_model': 'stock.immediate.transfer', 1006 'views': [(view.id, 'form')], 1007 'view_id': view.id, 1008 'target': 'new', 1009 'context': dict(self.env.context, default_show_transfers=show_transfers, default_pick_ids=[(4, p.id) for p in self]), 1010 } 1011 1012 def action_toggle_is_locked(self): 1013 self.ensure_one() 1014 self.is_locked = not self.is_locked 1015 return True 1016 1017 def _check_backorder(self): 1018 prec = self.env["decimal.precision"].precision_get("Product Unit of Measure") 1019 backorder_pickings = self.browse() 1020 for picking in self: 1021 quantity_todo = {} 1022 quantity_done = {} 1023 for move in picking.mapped('move_lines').filtered(lambda m: m.state != "cancel"): 1024 quantity_todo.setdefault(move.product_id.id, 0) 1025 quantity_done.setdefault(move.product_id.id, 0) 1026 quantity_todo[move.product_id.id] += move.product_uom._compute_quantity(move.product_uom_qty, move.product_id.uom_id, rounding_method='HALF-UP') 1027 quantity_done[move.product_id.id] += move.product_uom._compute_quantity(move.quantity_done, move.product_id.uom_id, rounding_method='HALF-UP') 1028 # FIXME: the next block doesn't seem nor should be used. 1029 for ops in picking.mapped('move_line_ids').filtered(lambda x: x.package_id and not x.product_id and not x.move_id): 1030 for quant in ops.package_id.quant_ids: 1031 quantity_done.setdefault(quant.product_id.id, 0) 1032 quantity_done[quant.product_id.id] += quant.qty 1033 for pack in picking.mapped('move_line_ids').filtered(lambda x: x.product_id and not x.move_id): 1034 quantity_done.setdefault(pack.product_id.id, 0) 1035 quantity_done[pack.product_id.id] += pack.product_uom_id._compute_quantity(pack.qty_done, pack.product_id.uom_id) 1036 if any( 1037 float_compare(quantity_done[x], quantity_todo.get(x, 0), precision_digits=prec,) == -1 1038 for x in quantity_done 1039 ): 1040 backorder_pickings |= picking 1041 return backorder_pickings 1042 1043 def _check_immediate(self): 1044 immediate_pickings = self.browse() 1045 precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure') 1046 for picking in self: 1047 if all(float_is_zero(move_line.qty_done, precision_digits=precision_digits) for move_line in picking.move_line_ids.filtered(lambda m: m.state not in ('done', 'cancel'))): 1048 immediate_pickings |= picking 1049 return immediate_pickings 1050 1051 def _autoconfirm_picking(self): 1052 """ Automatically run `action_confirm` on `self` if the picking is an immediate transfer or 1053 if the picking is a planned transfer and one of its move was added after the initial 1054 call to `action_confirm`. Note that `action_confirm` will only work on draft moves. 1055 """ 1056 # Clean-up the context key to avoid forcing the creation of immediate transfers. 1057 ctx = dict(self.env.context) 1058 ctx.pop('default_immediate_transfer', None) 1059 self = self.with_context(ctx) 1060 for picking in self: 1061 if picking.state in ('done', 'cancel'): 1062 continue 1063 if not picking.move_lines and not picking.package_level_ids: 1064 continue 1065 if picking.immediate_transfer or any(move.additional for move in picking.move_lines): 1066 picking.action_confirm() 1067 # Make sure the reservation is bypassed in immediate transfer mode. 1068 if picking.immediate_transfer: 1069 picking.move_lines.write({'state': 'assigned'}) 1070 1071 def _create_backorder(self): 1072 """ This method is called when the user chose to create a backorder. It will create a new 1073 picking, the backorder, and move the stock.moves that are not `done` or `cancel` into it. 1074 """ 1075 backorders = self.env['stock.picking'] 1076 for picking in self: 1077 moves_to_backorder = picking.move_lines.filtered(lambda x: x.state not in ('done', 'cancel')) 1078 if moves_to_backorder: 1079 backorder_picking = picking.copy({ 1080 'name': '/', 1081 'move_lines': [], 1082 'move_line_ids': [], 1083 'backorder_id': picking.id 1084 }) 1085 picking.message_post( 1086 body=_('The backorder <a href=# data-oe-model=stock.picking data-oe-id=%d>%s</a> has been created.') % ( 1087 backorder_picking.id, backorder_picking.name)) 1088 moves_to_backorder.write({'picking_id': backorder_picking.id}) 1089 moves_to_backorder.mapped('package_level_id').write({'picking_id':backorder_picking.id}) 1090 moves_to_backorder.mapped('move_line_ids').write({'picking_id': backorder_picking.id}) 1091 backorders |= backorder_picking 1092 return backorders 1093 1094 def _log_activity_get_documents(self, orig_obj_changes, stream_field, stream, sorted_method=False, groupby_method=False): 1095 """ Generic method to log activity. To use with 1096 _log_activity method. It either log on uppermost 1097 ongoing documents or following documents. This method 1098 find all the documents and responsible for which a note 1099 has to be log. It also generate a rendering_context in 1100 order to render a specific note by documents containing 1101 only the information relative to the document it. For example 1102 we don't want to notify a picking on move that it doesn't 1103 contain. 1104 1105 :param orig_obj_changes dict: contain a record as key and the 1106 change on this record as value. 1107 eg: {'move_id': (new product_uom_qty, old product_uom_qty)} 1108 :param stream_field string: It has to be a field of the 1109 records that are register in the key of 'orig_obj_changes' 1110 eg: 'move_dest_ids' if we use move as record (previous example) 1111 - 'UP' if we want to log on the upper most ongoing 1112 documents. 1113 - 'DOWN' if we want to log on following documents. 1114 :param sorted_method method, groupby_method: Only need when 1115 stream is 'DOWN', it should sort/group by tuple(object on 1116 which the activity is log, the responsible for this object) 1117 """ 1118 if self.env.context.get('skip_activity'): 1119 return {} 1120 move_to_orig_object_rel = {co: ooc for ooc in orig_obj_changes.keys() for co in ooc[stream_field]} 1121 origin_objects = self.env[list(orig_obj_changes.keys())[0]._name].concat(*list(orig_obj_changes.keys())) 1122 # The purpose here is to group each destination object by 1123 # (document to log, responsible) no matter the stream direction. 1124 # example: 1125 # {'(delivery_picking_1, admin)': stock.move(1, 2) 1126 # '(delivery_picking_2, admin)': stock.move(3)} 1127 visited_documents = {} 1128 if stream == 'DOWN': 1129 if sorted_method and groupby_method: 1130 grouped_moves = groupby(sorted(origin_objects.mapped(stream_field), key=sorted_method), key=groupby_method) 1131 else: 1132 raise UserError(_('You have to define a groupby and sorted method and pass them as arguments.')) 1133 elif stream == 'UP': 1134 # When using upstream document it is required to define 1135 # _get_upstream_documents_and_responsibles on 1136 # destination objects in order to ascend documents. 1137 grouped_moves = {} 1138 for visited_move in origin_objects.mapped(stream_field): 1139 for document, responsible, visited in visited_move._get_upstream_documents_and_responsibles(self.env[visited_move._name]): 1140 if grouped_moves.get((document, responsible)): 1141 grouped_moves[(document, responsible)] |= visited_move 1142 visited_documents[(document, responsible)] |= visited 1143 else: 1144 grouped_moves[(document, responsible)] = visited_move 1145 visited_documents[(document, responsible)] = visited 1146 grouped_moves = grouped_moves.items() 1147 else: 1148 raise UserError(_('Unknown stream.')) 1149 1150 documents = {} 1151 for (parent, responsible), moves in grouped_moves: 1152 if not parent: 1153 continue 1154 moves = list(moves) 1155 moves = self.env[moves[0]._name].concat(*moves) 1156 # Get the note 1157 rendering_context = {move: (orig_object, orig_obj_changes[orig_object]) for move in moves for orig_object in move_to_orig_object_rel[move]} 1158 if visited_documents: 1159 documents[(parent, responsible)] = rendering_context, visited_documents.values() 1160 else: 1161 documents[(parent, responsible)] = rendering_context 1162 return documents 1163 1164 def _log_activity(self, render_method, documents): 1165 """ Log a note for each documents, responsible pair in 1166 documents passed as argument. The render_method is then 1167 call in order to use a template and render it with a 1168 rendering_context. 1169 1170 :param documents dict: A tuple (document, responsible) as key. 1171 An activity will be log by key. A rendering_context as value. 1172 If used with _log_activity_get_documents. In 'DOWN' stream 1173 cases the rendering_context will be a dict with format: 1174 {'stream_object': ('orig_object', new_qty, old_qty)} 1175 'UP' stream will add all the documents browsed in order to 1176 get the final/upstream document present in the key. 1177 :param render_method method: a static function that will generate 1178 the html note to log on the activity. The render_method should 1179 use the args: 1180 - rendering_context dict: value of the documents argument 1181 the render_method should return a string with an html format 1182 :param stream string: 1183 """ 1184 for (parent, responsible), rendering_context in documents.items(): 1185 note = render_method(rendering_context) 1186 parent.activity_schedule( 1187 'mail.mail_activity_data_warning', 1188 date.today(), 1189 note=note, 1190 user_id=responsible.id or SUPERUSER_ID 1191 ) 1192 1193 def _log_less_quantities_than_expected(self, moves): 1194 """ Log an activity on picking that follow moves. The note 1195 contains the moves changes and all the impacted picking. 1196 1197 :param dict moves: a dict with a move as key and tuple with 1198 new and old quantity as value. eg: {move_1 : (4, 5)} 1199 """ 1200 def _keys_in_sorted(move): 1201 """ sort by picking and the responsible for the product the 1202 move. 1203 """ 1204 return (move.picking_id.id, move.product_id.responsible_id.id) 1205 1206 def _keys_in_groupby(move): 1207 """ group by picking and the responsible for the product the 1208 move. 1209 """ 1210 return (move.picking_id, move.product_id.responsible_id) 1211 1212 def _render_note_exception_quantity(rendering_context): 1213 """ :param rendering_context: 1214 {'move_dest': (move_orig, (new_qty, old_qty))} 1215 """ 1216 origin_moves = self.env['stock.move'].browse([move.id for move_orig in rendering_context.values() for move in move_orig[0]]) 1217 origin_picking = origin_moves.mapped('picking_id') 1218 move_dest_ids = self.env['stock.move'].concat(*rendering_context.keys()) 1219 impacted_pickings = origin_picking._get_impacted_pickings(move_dest_ids) - move_dest_ids.mapped('picking_id') 1220 values = { 1221 'origin_picking': origin_picking, 1222 'moves_information': rendering_context.values(), 1223 'impacted_pickings': impacted_pickings, 1224 } 1225 return self.env.ref('stock.exception_on_picking')._render(values=values) 1226 1227 documents = self._log_activity_get_documents(moves, 'move_dest_ids', 'DOWN', _keys_in_sorted, _keys_in_groupby) 1228 documents = self._less_quantities_than_expected_add_documents(moves, documents) 1229 self._log_activity(_render_note_exception_quantity, documents) 1230 1231 def _less_quantities_than_expected_add_documents(self, moves, documents): 1232 return documents 1233 1234 def _get_impacted_pickings(self, moves): 1235 """ This function is used in _log_less_quantities_than_expected 1236 the purpose is to notify a user with all the pickings that are 1237 impacted by an action on a chained move. 1238 param: 'moves' contain moves that belong to a common picking. 1239 return: all the pickings that contain a destination moves 1240 (direct and indirect) from the moves given as arguments. 1241 """ 1242 1243 def _explore(impacted_pickings, explored_moves, moves_to_explore): 1244 for move in moves_to_explore: 1245 if move not in explored_moves: 1246 impacted_pickings |= move.picking_id 1247 explored_moves |= move 1248 moves_to_explore |= move.move_dest_ids 1249 moves_to_explore = moves_to_explore - explored_moves 1250 if moves_to_explore: 1251 return _explore(impacted_pickings, explored_moves, moves_to_explore) 1252 else: 1253 return impacted_pickings 1254 1255 return _explore(self.env['stock.picking'], self.env['stock.move'], moves) 1256 1257 def _pre_put_in_pack_hook(self, move_line_ids): 1258 return self._check_destinations(move_line_ids) 1259 1260 def _check_destinations(self, move_line_ids): 1261 if len(move_line_ids.mapped('location_dest_id')) > 1: 1262 view_id = self.env.ref('stock.stock_package_destination_form_view').id 1263 wiz = self.env['stock.package.destination'].create({ 1264 'picking_id': self.id, 1265 'location_dest_id': move_line_ids[0].location_dest_id.id, 1266 }) 1267 return { 1268 'name': _('Choose destination location'), 1269 'view_mode': 'form', 1270 'res_model': 'stock.package.destination', 1271 'view_id': view_id, 1272 'views': [(view_id, 'form')], 1273 'type': 'ir.actions.act_window', 1274 'res_id': wiz.id, 1275 'target': 'new' 1276 } 1277 else: 1278 return {} 1279 1280 def _put_in_pack(self, move_line_ids, create_package_level=True): 1281 package = False 1282 for pick in self: 1283 move_lines_to_pack = self.env['stock.move.line'] 1284 package = self.env['stock.quant.package'].create({}) 1285 1286 precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure') 1287 if float_is_zero(move_line_ids[0].qty_done, precision_digits=precision_digits): 1288 for line in move_line_ids: 1289 line.qty_done = line.product_uom_qty 1290 1291 for ml in move_line_ids: 1292 if float_compare(ml.qty_done, ml.product_uom_qty, 1293 precision_rounding=ml.product_uom_id.rounding) >= 0: 1294 move_lines_to_pack |= ml 1295 else: 1296 quantity_left_todo = float_round( 1297 ml.product_uom_qty - ml.qty_done, 1298 precision_rounding=ml.product_uom_id.rounding, 1299 rounding_method='UP') 1300 done_to_keep = ml.qty_done 1301 new_move_line = ml.copy( 1302 default={'product_uom_qty': 0, 'qty_done': ml.qty_done}) 1303 vals = {'product_uom_qty': quantity_left_todo, 'qty_done': 0.0} 1304 if pick.picking_type_id.code == 'incoming': 1305 if ml.lot_id: 1306 vals['lot_id'] = False 1307 if ml.lot_name: 1308 vals['lot_name'] = False 1309 ml.write(vals) 1310 new_move_line.write({'product_uom_qty': done_to_keep}) 1311 move_lines_to_pack |= new_move_line 1312 if create_package_level: 1313 package_level = self.env['stock.package_level'].create({ 1314 'package_id': package.id, 1315 'picking_id': pick.id, 1316 'location_id': False, 1317 'location_dest_id': move_line_ids.mapped('location_dest_id').id, 1318 'move_line_ids': [(6, 0, move_lines_to_pack.ids)], 1319 'company_id': pick.company_id.id, 1320 }) 1321 move_lines_to_pack.write({ 1322 'result_package_id': package.id, 1323 }) 1324 return package 1325 1326 def action_put_in_pack(self): 1327 self.ensure_one() 1328 if self.state not in ('done', 'cancel'): 1329 picking_move_lines = self.move_line_ids 1330 if ( 1331 not self.picking_type_id.show_reserved 1332 and not self.immediate_transfer 1333 and not self.env.context.get('barcode_view') 1334 ): 1335 picking_move_lines = self.move_line_nosuggest_ids 1336 1337 move_line_ids = picking_move_lines.filtered(lambda ml: 1338 float_compare(ml.qty_done, 0.0, precision_rounding=ml.product_uom_id.rounding) > 0 1339 and not ml.result_package_id 1340 ) 1341 if not move_line_ids: 1342 move_line_ids = picking_move_lines.filtered(lambda ml: float_compare(ml.product_uom_qty, 0.0, 1343 precision_rounding=ml.product_uom_id.rounding) > 0 and float_compare(ml.qty_done, 0.0, 1344 precision_rounding=ml.product_uom_id.rounding) == 0) 1345 if move_line_ids: 1346 res = self._pre_put_in_pack_hook(move_line_ids) 1347 if not res: 1348 res = self._put_in_pack(move_line_ids) 1349 return res 1350 else: 1351 raise UserError(_("Please add 'Done' quantities to the picking to create a new pack.")) 1352 1353 def button_scrap(self): 1354 self.ensure_one() 1355 view = self.env.ref('stock.stock_scrap_form_view2') 1356 products = self.env['product.product'] 1357 for move in self.move_lines: 1358 if move.state not in ('draft', 'cancel') and move.product_id.type in ('product', 'consu'): 1359 products |= move.product_id 1360 return { 1361 'name': _('Scrap'), 1362 'view_mode': 'form', 1363 'res_model': 'stock.scrap', 1364 'view_id': view.id, 1365 'views': [(view.id, 'form')], 1366 'type': 'ir.actions.act_window', 1367 'context': {'default_picking_id': self.id, 'product_ids': products.ids, 'default_company_id': self.company_id.id}, 1368 'target': 'new', 1369 } 1370 1371 def action_see_move_scrap(self): 1372 self.ensure_one() 1373 action = self.env["ir.actions.actions"]._for_xml_id("stock.action_stock_scrap") 1374 scraps = self.env['stock.scrap'].search([('picking_id', '=', self.id)]) 1375 action['domain'] = [('id', 'in', scraps.ids)] 1376 action['context'] = dict(self._context, create=False) 1377 return action 1378 1379 def action_see_packages(self): 1380 self.ensure_one() 1381 action = self.env["ir.actions.actions"]._for_xml_id("stock.action_package_view") 1382 packages = self.move_line_ids.mapped('result_package_id') 1383 action['domain'] = [('id', 'in', packages.ids)] 1384 action['context'] = {'picking_id': self.id} 1385 return action 1386 1387 def action_picking_move_tree(self): 1388 action = self.env["ir.actions.actions"]._for_xml_id("stock.stock_move_action") 1389 action['views'] = [ 1390 (self.env.ref('stock.view_picking_move_tree').id, 'tree'), 1391 ] 1392 action['context'] = self.env.context 1393 action['domain'] = [('picking_id', 'in', self.ids)] 1394 return action 1395 1396 def _attach_sign(self): 1397 """ Render the delivery report in pdf and attach it to the picking in `self`. """ 1398 self.ensure_one() 1399 report = self.env.ref('stock.action_report_delivery')._render_qweb_pdf(self.id) 1400 filename = "%s_signed_delivery_slip" % self.name 1401 if self.partner_id: 1402 message = _('Order signed by %s') % (self.partner_id.name) 1403 else: 1404 message = _('Order signed') 1405 self.message_post( 1406 attachments=[('%s.pdf' % filename, report[0])], 1407 body=message, 1408 ) 1409 return True 1410