1# -*- coding: utf-8 -*- 2# Part of Odoo. See LICENSE file for full copyright and licensing details. 3 4import json 5from collections import defaultdict 6from datetime import datetime 7from itertools import groupby 8from operator import itemgetter 9from re import findall as regex_findall 10from re import split as regex_split 11 12from dateutil import relativedelta 13 14from odoo import SUPERUSER_ID, _, api, fields, models 15from odoo.exceptions import UserError 16from odoo.osv import expression 17from odoo.tools.float_utils import float_compare, float_is_zero, float_repr, float_round 18from odoo.tools.misc import format_date, OrderedSet 19 20PROCUREMENT_PRIORITIES = [('0', 'Normal'), ('1', 'Urgent')] 21 22 23class StockMove(models.Model): 24 _name = "stock.move" 25 _description = "Stock Move" 26 _order = 'sequence, id' 27 28 def _default_group_id(self): 29 if self.env.context.get('default_picking_id'): 30 return self.env['stock.picking'].browse(self.env.context['default_picking_id']).group_id.id 31 return False 32 33 name = fields.Char('Description', index=True, required=True) 34 sequence = fields.Integer('Sequence', default=10) 35 priority = fields.Selection( 36 PROCUREMENT_PRIORITIES, 'Priority', default='0', 37 compute="_compute_priority", store=True, index=True) 38 create_date = fields.Datetime('Creation Date', index=True, readonly=True) 39 date = fields.Datetime( 40 'Date Scheduled', default=fields.Datetime.now, index=True, required=True, 41 help="Scheduled date until move is done, then date of actual move processing") 42 date_deadline = fields.Datetime( 43 "Deadline", readonly=True, 44 help="Date Promise to the customer on the top level document (SO/PO)") 45 company_id = fields.Many2one( 46 'res.company', 'Company', 47 default=lambda self: self.env.company, 48 index=True, required=True) 49 product_id = fields.Many2one( 50 'product.product', 'Product', 51 check_company=True, 52 domain="[('type', 'in', ['product', 'consu']), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", index=True, required=True, 53 states={'done': [('readonly', True)]}) 54 description_picking = fields.Text('Description of Picking') 55 product_qty = fields.Float( 56 'Real Quantity', compute='_compute_product_qty', inverse='_set_product_qty', 57 digits=0, store=True, compute_sudo=True, 58 help='Quantity in the default UoM of the product') 59 product_uom_qty = fields.Float( 60 'Demand', 61 digits='Product Unit of Measure', 62 default=0.0, required=True, states={'done': [('readonly', True)]}, 63 help="This is the quantity of products from an inventory " 64 "point of view. For moves in the state 'done', this is the " 65 "quantity of products that were actually moved. For other " 66 "moves, this is the quantity of product that is planned to " 67 "be moved. Lowering this quantity does not generate a " 68 "backorder. Changing this quantity on assigned moves affects " 69 "the product reservation, and should be done with care.") 70 product_uom = fields.Many2one('uom.uom', 'Unit of Measure', required=True, domain="[('category_id', '=', product_uom_category_id)]") 71 product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id') 72 # TDE FIXME: make it stored, otherwise group will not work 73 product_tmpl_id = fields.Many2one( 74 'product.template', 'Product Template', 75 related='product_id.product_tmpl_id', readonly=False, 76 help="Technical: used in views") 77 location_id = fields.Many2one( 78 'stock.location', 'Source Location', 79 auto_join=True, index=True, required=True, 80 check_company=True, 81 help="Sets a location if you produce at a fixed location. This can be a partner location if you subcontract the manufacturing operations.") 82 location_dest_id = fields.Many2one( 83 'stock.location', 'Destination Location', 84 auto_join=True, index=True, required=True, 85 check_company=True, 86 help="Location where the system will stock the finished products.") 87 partner_id = fields.Many2one( 88 'res.partner', 'Destination Address ', 89 states={'done': [('readonly', True)]}, 90 help="Optional address where goods are to be delivered, specifically used for allotment") 91 move_dest_ids = fields.Many2many( 92 'stock.move', 'stock_move_move_rel', 'move_orig_id', 'move_dest_id', 'Destination Moves', 93 copy=False, 94 help="Optional: next stock move when chaining them") 95 move_orig_ids = fields.Many2many( 96 'stock.move', 'stock_move_move_rel', 'move_dest_id', 'move_orig_id', 'Original Move', 97 copy=False, 98 help="Optional: previous stock move when chaining them") 99 picking_id = fields.Many2one('stock.picking', 'Transfer', index=True, states={'done': [('readonly', True)]}, check_company=True) 100 picking_partner_id = fields.Many2one('res.partner', 'Transfer Destination Address', related='picking_id.partner_id', readonly=False) 101 note = fields.Text('Notes') 102 state = fields.Selection([ 103 ('draft', 'New'), ('cancel', 'Cancelled'), 104 ('waiting', 'Waiting Another Move'), 105 ('confirmed', 'Waiting Availability'), 106 ('partially_available', 'Partially Available'), 107 ('assigned', 'Available'), 108 ('done', 'Done')], string='Status', 109 copy=False, default='draft', index=True, readonly=True, 110 help="* New: When the stock move is created and not yet confirmed.\n" 111 "* Waiting Another Move: This state can be seen when a move is waiting for another one, for example in a chained flow.\n" 112 "* Waiting Availability: This state is reached when the procurement resolution is not straight forward. It may need the scheduler to run, a component to be manufactured...\n" 113 "* Available: When products are reserved, it is set to \'Available\'.\n" 114 "* Done: When the shipment is processed, the state is \'Done\'.") 115 price_unit = fields.Float( 116 'Unit Price', help="Technical field used to record the product cost set by the user during a picking confirmation (when costing " 117 "method used is 'average price' or 'real'). Value given in company currency and in product uom.", copy=False) # as it's a technical field, we intentionally don't provide the digits attribute 118 backorder_id = fields.Many2one('stock.picking', 'Back Order of', related='picking_id.backorder_id', index=True, readonly=False) 119 origin = fields.Char("Source Document") 120 procure_method = fields.Selection([ 121 ('make_to_stock', 'Default: Take From Stock'), 122 ('make_to_order', 'Advanced: Apply Procurement Rules')], string='Supply Method', 123 default='make_to_stock', required=True, 124 help="By default, the system will take from the stock in the source location and passively wait for availability. " 125 "The other possibility allows you to directly create a procurement on the source location (and thus ignore " 126 "its current stock) to gather products. If we want to chain moves and have this one to wait for the previous, " 127 "this second option should be chosen.") 128 scrapped = fields.Boolean('Scrapped', related='location_dest_id.scrap_location', readonly=True, store=True) 129 scrap_ids = fields.One2many('stock.scrap', 'move_id') 130 group_id = fields.Many2one('procurement.group', 'Procurement Group', default=_default_group_id) 131 rule_id = fields.Many2one( 132 'stock.rule', 'Stock Rule', ondelete='restrict', help='The stock rule that created this stock move', 133 check_company=True) 134 propagate_cancel = fields.Boolean( 135 'Propagate cancel and split', default=True, 136 help='If checked, when this move is cancelled, cancel the linked move too') 137 delay_alert_date = fields.Datetime('Delay Alert Date', help='Process at this date to be on time', compute="_compute_delay_alert_date", store=True) 138 picking_type_id = fields.Many2one('stock.picking.type', 'Operation Type', check_company=True) 139 inventory_id = fields.Many2one('stock.inventory', 'Inventory', check_company=True) 140 move_line_ids = fields.One2many('stock.move.line', 'move_id') 141 move_line_nosuggest_ids = fields.One2many('stock.move.line', 'move_id', domain=['|', ('product_qty', '=', 0.0), ('qty_done', '!=', 0.0)]) 142 origin_returned_move_id = fields.Many2one( 143 'stock.move', 'Origin return move', copy=False, index=True, 144 help='Move that created the return move', check_company=True) 145 returned_move_ids = fields.One2many('stock.move', 'origin_returned_move_id', 'All returned moves', help='Optional: all returned moves created from this move') 146 reserved_availability = fields.Float( 147 'Quantity Reserved', compute='_compute_reserved_availability', 148 digits='Product Unit of Measure', 149 readonly=True, help='Quantity that has already been reserved for this move') 150 availability = fields.Float( 151 'Forecasted Quantity', compute='_compute_product_availability', 152 readonly=True, help='Quantity in stock that can still be reserved for this move') 153 restrict_partner_id = fields.Many2one( 154 'res.partner', 'Owner ', help="Technical field used to depict a restriction on the ownership of quants to consider when marking this move as 'done'", 155 check_company=True) 156 route_ids = fields.Many2many( 157 'stock.location.route', 'stock_location_route_move', 'move_id', 'route_id', 'Destination route', help="Preferred route", 158 check_company=True) 159 warehouse_id = fields.Many2one('stock.warehouse', 'Warehouse', help="Technical field depicting the warehouse to consider for the route selection on the next procurement (if any).") 160 has_tracking = fields.Selection(related='product_id.tracking', string='Product with Tracking') 161 quantity_done = fields.Float('Quantity Done', compute='_quantity_done_compute', digits='Product Unit of Measure', inverse='_quantity_done_set') 162 show_operations = fields.Boolean(related='picking_id.picking_type_id.show_operations', readonly=False) 163 show_details_visible = fields.Boolean('Details Visible', compute='_compute_show_details_visible') 164 show_reserved_availability = fields.Boolean('From Supplier', compute='_compute_show_reserved_availability') 165 picking_code = fields.Selection(related='picking_id.picking_type_id.code', readonly=True) 166 product_type = fields.Selection(related='product_id.type', readonly=True) 167 additional = fields.Boolean("Whether the move was added after the picking's confirmation", default=False) 168 is_locked = fields.Boolean(compute='_compute_is_locked', readonly=True) 169 is_initial_demand_editable = fields.Boolean('Is initial demand editable', compute='_compute_is_initial_demand_editable') 170 is_quantity_done_editable = fields.Boolean('Is quantity done editable', compute='_compute_is_quantity_done_editable') 171 reference = fields.Char(compute='_compute_reference', string="Reference", store=True) 172 has_move_lines = fields.Boolean(compute='_compute_has_move_lines') 173 package_level_id = fields.Many2one('stock.package_level', 'Package Level', check_company=True, copy=False) 174 picking_type_entire_packs = fields.Boolean(related='picking_type_id.show_entire_packs', readonly=True) 175 display_assign_serial = fields.Boolean(compute='_compute_display_assign_serial') 176 next_serial = fields.Char('First SN') 177 next_serial_count = fields.Integer('Number of SN') 178 orderpoint_id = fields.Many2one('stock.warehouse.orderpoint', 'Original Reordering Rule', check_company=True) 179 forecast_availability = fields.Float('Forecast Availability', compute='_compute_forecast_information', digits='Product Unit of Measure', compute_sudo=True) 180 forecast_expected_date = fields.Datetime('Forecasted Expected date', compute='_compute_forecast_information', compute_sudo=True) 181 lot_ids = fields.Many2many('stock.production.lot', compute='_compute_lot_ids', inverse='_set_lot_ids', string='Serial Numbers', readonly=False) 182 183 @api.onchange('product_id', 'picking_type_id') 184 def onchange_product(self): 185 if self.product_id: 186 product = self.product_id.with_context(lang=self._get_lang()) 187 self.description_picking = product._get_description(self.picking_type_id) 188 189 @api.depends('has_tracking', 'picking_type_id.use_create_lots', 'picking_type_id.use_existing_lots', 'state') 190 def _compute_display_assign_serial(self): 191 for move in self: 192 move.display_assign_serial = ( 193 move.has_tracking == 'serial' and 194 move.state in ('partially_available', 'assigned', 'confirmed') and 195 move.picking_type_id.use_create_lots and 196 not move.picking_type_id.use_existing_lots 197 and not move.origin_returned_move_id.id 198 ) 199 200 @api.depends('picking_id.priority') 201 def _compute_priority(self): 202 for move in self: 203 move.priority = move.picking_id.priority or '0' 204 205 @api.depends('picking_id.is_locked') 206 def _compute_is_locked(self): 207 for move in self: 208 if move.picking_id: 209 move.is_locked = move.picking_id.is_locked 210 else: 211 move.is_locked = False 212 213 @api.depends('product_id', 'has_tracking', 'move_line_ids') 214 def _compute_show_details_visible(self): 215 """ According to this field, the button that calls `action_show_details` will be displayed 216 to work on a move from its picking form view, or not. 217 """ 218 has_package = self.user_has_groups('stock.group_tracking_lot') 219 multi_locations_enabled = self.user_has_groups('stock.group_stock_multi_locations') 220 consignment_enabled = self.user_has_groups('stock.group_tracking_owner') 221 222 show_details_visible = multi_locations_enabled or has_package 223 224 for move in self: 225 if not move.product_id: 226 move.show_details_visible = False 227 elif len(move.move_line_ids) > 1: 228 move.show_details_visible = True 229 else: 230 move.show_details_visible = (((consignment_enabled and move.picking_id.picking_type_id.code != 'incoming') or 231 show_details_visible or move.has_tracking != 'none') and 232 move._show_details_in_draft() and 233 move.picking_id.picking_type_id.show_operations is False) 234 235 def _compute_show_reserved_availability(self): 236 """ This field is only of use in an attrs in the picking view, in order to hide the 237 "available" column if the move is coming from a supplier. 238 """ 239 for move in self: 240 move.show_reserved_availability = not move.location_id.usage == 'supplier' 241 242 @api.depends('state', 'picking_id') 243 def _compute_is_initial_demand_editable(self): 244 for move in self: 245 if not move.picking_id.immediate_transfer and move.state == 'draft': 246 move.is_initial_demand_editable = True 247 elif not move.picking_id.is_locked and move.state != 'done' and move.picking_id: 248 move.is_initial_demand_editable = True 249 else: 250 move.is_initial_demand_editable = False 251 252 @api.depends('state', 'picking_id', 'product_id') 253 def _compute_is_quantity_done_editable(self): 254 for move in self: 255 if not move.product_id: 256 move.is_quantity_done_editable = False 257 elif not move.picking_id.immediate_transfer and move.picking_id.state == 'draft': 258 move.is_quantity_done_editable = False 259 elif move.picking_id.is_locked and move.state in ('done', 'cancel'): 260 move.is_quantity_done_editable = False 261 elif move.show_details_visible: 262 move.is_quantity_done_editable = False 263 elif move.show_operations: 264 move.is_quantity_done_editable = False 265 else: 266 move.is_quantity_done_editable = True 267 268 @api.depends('picking_id', 'name') 269 def _compute_reference(self): 270 for move in self: 271 move.reference = move.picking_id.name if move.picking_id else move.name 272 273 @api.depends('move_line_ids') 274 def _compute_has_move_lines(self): 275 for move in self: 276 move.has_move_lines = bool(move.move_line_ids) 277 278 @api.depends('product_id', 'product_uom', 'product_uom_qty') 279 def _compute_product_qty(self): 280 # DLE FIXME: `stock/tests/test_move2.py` 281 # `product_qty` is a STORED compute field which depends on the context :/ 282 # I asked SLE to change this, task: 2041971 283 # In the mean time I cheat and force the rouding to half-up, it seems it works for all tests. 284 rounding_method = 'HALF-UP' 285 for move in self: 286 move.product_qty = move.product_uom._compute_quantity( 287 move.product_uom_qty, move.product_id.uom_id, rounding_method=rounding_method) 288 289 def _get_move_lines(self): 290 """ This will return the move lines to consider when applying _quantity_done_compute on a stock.move. 291 In some context, such as MRP, it is necessary to compute quantity_done on filtered sock.move.line.""" 292 self.ensure_one() 293 if self.picking_type_id.show_reserved is False: 294 return self.move_line_nosuggest_ids 295 return self.move_line_ids 296 297 @api.depends('move_orig_ids.date', 'move_orig_ids.state', 'state', 'date') 298 def _compute_delay_alert_date(self): 299 for move in self: 300 if move.state in ('done', 'cancel'): 301 move.delay_alert_date = False 302 continue 303 prev_moves = move.move_orig_ids.filtered(lambda m: m.state not in ('done', 'cancel') and m.date) 304 prev_max_date = max(prev_moves.mapped("date"), default=False) 305 if prev_max_date and prev_max_date > move.date: 306 move.delay_alert_date = prev_max_date 307 else: 308 move.delay_alert_date = False 309 310 @api.depends('move_line_ids.qty_done', 'move_line_ids.product_uom_id', 'move_line_nosuggest_ids.qty_done', 'picking_type_id') 311 def _quantity_done_compute(self): 312 """ This field represents the sum of the move lines `qty_done`. It allows the user to know 313 if there is still work to do. 314 315 We take care of rounding this value at the general decimal precision and not the rounding 316 of the move's UOM to make sure this value is really close to the real sum, because this 317 field will be used in `_action_done` in order to know if the move will need a backorder or 318 an extra move. 319 """ 320 if not any(self._ids): 321 # onchange 322 for move in self: 323 quantity_done = 0 324 for move_line in move._get_move_lines(): 325 quantity_done += move_line.product_uom_id._compute_quantity( 326 move_line.qty_done, move.product_uom, round=False) 327 move.quantity_done = quantity_done 328 else: 329 # compute 330 move_lines_ids = set() 331 for move in self: 332 move_lines_ids |= set(move._get_move_lines().ids) 333 334 data = self.env['stock.move.line'].read_group( 335 [('id', 'in', list(move_lines_ids))], 336 ['move_id', 'product_uom_id', 'qty_done'], ['move_id', 'product_uom_id'], 337 lazy=False 338 ) 339 340 rec = defaultdict(list) 341 for d in data: 342 rec[d['move_id'][0]] += [(d['product_uom_id'][0], d['qty_done'])] 343 344 for move in self: 345 uom = move.product_uom 346 move.quantity_done = sum( 347 self.env['uom.uom'].browse(line_uom_id)._compute_quantity(qty, uom, round=False) 348 for line_uom_id, qty in rec.get(move.ids[0] if move.ids else move.id, []) 349 ) 350 351 def _quantity_done_set(self): 352 quantity_done = self[0].quantity_done # any call to create will invalidate `move.quantity_done` 353 for move in self: 354 move_lines = move._get_move_lines() 355 if not move_lines: 356 if quantity_done: 357 # do not impact reservation here 358 move_line = self.env['stock.move.line'].create(dict(move._prepare_move_line_vals(), qty_done=quantity_done)) 359 move.write({'move_line_ids': [(4, move_line.id)]}) 360 elif len(move_lines) == 1: 361 move_lines[0].qty_done = quantity_done 362 else: 363 # Bypass the error if we're trying to write the same value. 364 ml_quantity_done = 0 365 for move_line in move_lines: 366 ml_quantity_done += move_line.product_uom_id._compute_quantity(move_line.qty_done, move.product_uom, round=False) 367 if float_compare(quantity_done, ml_quantity_done, precision_rounding=move.product_uom.rounding) != 0: 368 raise UserError(_("Cannot set the done quantity from this stock move, work directly with the move lines.")) 369 370 def _set_product_qty(self): 371 """ The meaning of product_qty field changed lately and is now a functional field computing the quantity 372 in the default product UoM. This code has been added to raise an error if a write is made given a value 373 for `product_qty`, where the same write should set the `product_uom_qty` field instead, in order to 374 detect errors. """ 375 raise UserError(_('The requested operation cannot be processed because of a programming error setting the `product_qty` field instead of the `product_uom_qty`.')) 376 377 @api.depends('move_line_ids.product_qty') 378 def _compute_reserved_availability(self): 379 """ Fill the `availability` field on a stock move, which is the actual reserved quantity 380 and is represented by the aggregated `product_qty` on the linked move lines. If the move 381 is force assigned, the value will be 0. 382 """ 383 if not any(self._ids): 384 # onchange 385 for move in self: 386 reserved_availability = sum(move.move_line_ids.mapped('product_qty')) 387 move.reserved_availability = move.product_id.uom_id._compute_quantity( 388 reserved_availability, move.product_uom, rounding_method='HALF-UP') 389 else: 390 # compute 391 result = {data['move_id'][0]: data['product_qty'] for data in 392 self.env['stock.move.line'].read_group([('move_id', 'in', self.ids)], ['move_id', 'product_qty'], ['move_id'])} 393 for move in self: 394 move.reserved_availability = move.product_id.uom_id._compute_quantity( 395 result.get(move.id, 0.0), move.product_uom, rounding_method='HALF-UP') 396 397 @api.depends('state', 'product_id', 'product_qty', 'location_id') 398 def _compute_product_availability(self): 399 """ Fill the `availability` field on a stock move, which is the quantity to potentially 400 reserve. When the move is done, `availability` is set to the quantity the move did actually 401 move. 402 """ 403 for move in self: 404 if move.state == 'done': 405 move.availability = move.product_qty 406 else: 407 total_availability = self.env['stock.quant']._get_available_quantity(move.product_id, move.location_id) if move.product_id else 0.0 408 move.availability = min(move.product_qty, total_availability) 409 410 @api.depends('product_id', 'picking_type_id', 'picking_id', 'reserved_availability', 'priority', 'state', 'product_uom_qty', 'location_id') 411 def _compute_forecast_information(self): 412 """ Compute forecasted information of the related product by warehouse.""" 413 self.forecast_availability = False 414 self.forecast_expected_date = False 415 416 not_product_moves = self.filtered(lambda move: move.product_id.type != 'product') 417 for move in not_product_moves: 418 move.forecast_availability = move.product_qty 419 420 product_moves = (self - not_product_moves) 421 warehouse_by_location = {loc: loc.get_warehouse() for loc in product_moves.location_id} 422 423 outgoing_unreserved_moves_per_warehouse = defaultdict(lambda: self.env['stock.move']) 424 for move in product_moves: 425 picking_type = move.picking_type_id or move.picking_id.picking_type_id 426 is_unreserved = move.state in ('waiting', 'confirmed', 'partially_available') 427 if picking_type.code in self._consuming_picking_types() and is_unreserved: 428 outgoing_unreserved_moves_per_warehouse[warehouse_by_location[move.location_id]] |= move 429 elif picking_type.code in self._consuming_picking_types(): 430 move.forecast_availability = move.product_uom._compute_quantity( 431 move.reserved_availability, move.product_id.uom_id, rounding_method='HALF-UP') 432 433 for warehouse, moves in outgoing_unreserved_moves_per_warehouse.items(): 434 if not warehouse: # No prediction possible if no warehouse. 435 continue 436 product_variant_ids = moves.product_id.ids 437 wh_location_ids = [loc['id'] for loc in self.env['stock.location'].search_read( 438 [('id', 'child_of', warehouse.view_location_id.id)], 439 ['id'], 440 )] 441 ForecastedReport = self.env['report.stock.report_product_product_replenishment'] 442 forecast_lines = ForecastedReport.with_context(warehouse=warehouse.id)._get_report_lines(None, product_variant_ids, wh_location_ids) 443 for move in moves: 444 lines = [l for l in forecast_lines if l["move_out"] == move._origin and l["replenishment_filled"] is True] 445 if lines: 446 move.forecast_availability = sum(m['quantity'] for m in lines) 447 move_ins_lines = list(filter(lambda report_line: report_line['move_in'], lines)) 448 if move_ins_lines: 449 expected_date = max(m['move_in'].date for m in move_ins_lines) 450 move.forecast_expected_date = expected_date 451 452 def _set_date_deadline(self, new_deadline): 453 # Handle the propagation of `date_deadline` fields (up and down stream - only update by up/downstream documents) 454 already_propagate_ids = self.env.context.get('date_deadline_propagate_ids', set()) | set(self.ids) 455 self = self.with_context(date_deadline_propagate_ids=already_propagate_ids) 456 for move in self: 457 moves_to_update = (move.move_dest_ids | move.move_orig_ids) 458 if move.date_deadline: 459 delta = move.date_deadline - fields.Datetime.to_datetime(new_deadline) 460 else: 461 delta = 0 462 for move_update in moves_to_update: 463 if move_update.state in ('done', 'cancel'): 464 continue 465 if move_update.id in already_propagate_ids: 466 continue 467 if move_update.date_deadline and delta: 468 move_update.date_deadline -= delta 469 else: 470 move_update.date_deadline = new_deadline 471 472 @api.depends('move_line_ids', 'move_line_ids.lot_id', 'move_line_ids.qty_done') 473 def _compute_lot_ids(self): 474 domain_nosuggest = [('move_id', 'in', self.ids), ('lot_id', '!=', False), '|', ('qty_done', '!=', 0.0), ('product_qty', '=', 0.0)] 475 domain_suggest = [('move_id', 'in', self.ids), ('lot_id', '!=', False), ('qty_done', '!=', 0.0)] 476 lots_by_move_id_list = [] 477 for domain in [domain_nosuggest, domain_suggest]: 478 lots_by_move_id = self.env['stock.move.line'].read_group( 479 domain, 480 ['move_id', 'lot_ids:array_agg(lot_id)'], ['move_id'], 481 ) 482 lots_by_move_id_list.append({by_move['move_id'][0]: by_move['lot_ids'] for by_move in lots_by_move_id}) 483 for move in self: 484 move.lot_ids = lots_by_move_id_list[0 if move.picking_type_id.show_reserved else 1].get(move._origin.id, []) 485 486 def _set_lot_ids(self): 487 for move in self: 488 move_lines_commands = [] 489 if move.picking_type_id.show_reserved is False: 490 mls = move.move_line_nosuggest_ids 491 else: 492 mls = move.move_line_ids 493 mls = mls.filtered(lambda ml: ml.lot_id) 494 for ml in mls: 495 if ml.qty_done and ml.lot_id not in move.lot_ids: 496 move_lines_commands.append((2, ml.id)) 497 ls = move.move_line_ids.lot_id 498 for lot in move.lot_ids: 499 if lot not in ls: 500 move_line_vals = self._prepare_move_line_vals(quantity=0) 501 move_line_vals['lot_id'] = lot.id 502 move_line_vals['lot_name'] = lot.name 503 move_line_vals['product_uom_id'] = move.product_id.uom_id.id 504 move_line_vals['qty_done'] = 1 505 move_lines_commands.append((0, 0, move_line_vals)) 506 move.write({'move_line_ids': move_lines_commands}) 507 508 @api.constrains('product_uom') 509 def _check_uom(self): 510 moves_error = self.filtered(lambda move: move.product_id.uom_id.category_id != move.product_uom.category_id) 511 if moves_error: 512 user_warning = _('You cannot perform the move because the unit of measure has a different category as the product unit of measure.') 513 for move in moves_error: 514 user_warning += _('\n\n%s --> Product UoM is %s (%s) - Move UoM is %s (%s)') % (move.product_id.display_name, move.product_id.uom_id.name, move.product_id.uom_id.category_id.name, move.product_uom.name, move.product_uom.category_id.name) 515 user_warning += _('\n\nBlocking: %s') % ' ,'.join(moves_error.mapped('name')) 516 raise UserError(user_warning) 517 518 def init(self): 519 self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('stock_move_product_location_index',)) 520 if not self._cr.fetchone(): 521 self._cr.execute('CREATE INDEX stock_move_product_location_index ON stock_move (product_id, location_id, location_dest_id, company_id, state)') 522 523 @api.model 524 def default_get(self, fields_list): 525 # We override the default_get to make stock moves created after the picking was confirmed 526 # directly as available in immediate transfer mode. This allows to create extra move lines 527 # in the fp view. In planned transfer, the stock move are marked as `additional` and will be 528 # auto-confirmed. 529 defaults = super(StockMove, self).default_get(fields_list) 530 if self.env.context.get('default_picking_id'): 531 picking_id = self.env['stock.picking'].browse(self.env.context['default_picking_id']) 532 if picking_id.state == 'done': 533 defaults['state'] = 'done' 534 defaults['product_uom_qty'] = 0.0 535 defaults['additional'] = True 536 elif picking_id.state not in ['cancel', 'draft', 'done']: 537 if picking_id.immediate_transfer: 538 defaults['state'] = 'assigned' 539 defaults['product_uom_qty'] = 0.0 540 defaults['additional'] = True # to trigger `_autoconfirm_picking` 541 return defaults 542 543 def name_get(self): 544 res = [] 545 for move in self: 546 res.append((move.id, '%s%s%s>%s' % ( 547 move.picking_id.origin and '%s/' % move.picking_id.origin or '', 548 move.product_id.code and '%s: ' % move.product_id.code or '', 549 move.location_id.name, move.location_dest_id.name))) 550 return res 551 552 def write(self, vals): 553 # Handle the write on the initial demand by updating the reserved quantity and logging 554 # messages according to the state of the stock.move records. 555 receipt_moves_to_reassign = self.env['stock.move'] 556 move_to_recompute_state = self.env['stock.move'] 557 if 'product_uom_qty' in vals: 558 move_to_unreserve = self.env['stock.move'] 559 for move in self.filtered(lambda m: m.state not in ('done', 'draft') and m.picking_id): 560 if float_compare(vals['product_uom_qty'], move.product_uom_qty, precision_rounding=move.product_uom.rounding): 561 self.env['stock.move.line']._log_message(move.picking_id, move, 'stock.track_move_template', vals) 562 if self.env.context.get('do_not_unreserve') is None: 563 move_to_unreserve = self.filtered( 564 lambda m: m.state not in ['draft', 'done', 'cancel'] and float_compare(m.reserved_availability, vals.get('product_uom_qty'), precision_rounding=m.product_uom.rounding) == 1 565 ) 566 move_to_unreserve._do_unreserve() 567 (self - move_to_unreserve).filtered(lambda m: m.state == 'assigned').write({'state': 'partially_available'}) 568 # When editing the initial demand, directly run again action assign on receipt moves. 569 receipt_moves_to_reassign |= move_to_unreserve.filtered(lambda m: m.location_id.usage == 'supplier') 570 receipt_moves_to_reassign |= (self - move_to_unreserve).filtered(lambda m: m.location_id.usage == 'supplier' and m.state in ('partially_available', 'assigned')) 571 move_to_recompute_state |= self - move_to_unreserve - receipt_moves_to_reassign 572 if 'date_deadline' in vals: 573 self._set_date_deadline(vals.get('date_deadline')) 574 res = super(StockMove, self).write(vals) 575 if move_to_recompute_state: 576 move_to_recompute_state._recompute_state() 577 if receipt_moves_to_reassign: 578 receipt_moves_to_reassign._action_assign() 579 return res 580 581 def _delay_alert_get_documents(self): 582 """Returns a list of recordset of the documents linked to the stock.move in `self` in order 583 to post the delay alert next activity. These documents are deduplicated. This method is meant 584 to be overridden by other modules, each of them adding an element by type of recordset on 585 this list. 586 587 :return: a list of recordset of the documents linked to `self` 588 :rtype: list 589 """ 590 return list(self.mapped('picking_id')) 591 592 def _propagate_date_log_note(self, move_orig): 593 """Post a deadline change alert log note on the documents linked to `self`.""" 594 # TODO : get the end document (PO/SO/MO) 595 doc_orig = move_orig._delay_alert_get_documents() 596 documents = self._delay_alert_get_documents() 597 if not documents or not doc_orig: 598 return 599 600 msg = _("The deadline has been automatically updated due to a delay on <a href='#' data-oe-model='%s' data-oe-id='%s'>%s</a>.") % (doc_orig[0]._name, doc_orig[0].id, doc_orig[0].name) 601 msg_subject = _("Deadline updated due to delay on %s", doc_orig[0].name) 602 # write the message on each document 603 for doc in documents: 604 last_message = doc.message_ids[:1] 605 # Avoids to write the exact same message multiple times. 606 if last_message and last_message.subject == msg_subject: 607 continue 608 odoobot_id = self.env['ir.model.data'].xmlid_to_res_id("base.partner_root") 609 doc.message_post(body=msg, author_id=odoobot_id, subject=msg_subject) 610 611 def action_show_details(self): 612 """ Returns an action that will open a form view (in a popup) allowing to work on all the 613 move lines of a particular move. This form view is used when "show operations" is not 614 checked on the picking type. 615 """ 616 self.ensure_one() 617 618 picking_type_id = self.picking_type_id or self.picking_id.picking_type_id 619 620 # If "show suggestions" is not checked on the picking type, we have to filter out the 621 # reserved move lines. We do this by displaying `move_line_nosuggest_ids`. We use 622 # different views to display one field or another so that the webclient doesn't have to 623 # fetch both. 624 if picking_type_id.show_reserved: 625 view = self.env.ref('stock.view_stock_move_operations') 626 else: 627 view = self.env.ref('stock.view_stock_move_nosuggest_operations') 628 629 return { 630 'name': _('Detailed Operations'), 631 'type': 'ir.actions.act_window', 632 'view_mode': 'form', 633 'res_model': 'stock.move', 634 'views': [(view.id, 'form')], 635 'view_id': view.id, 636 'target': 'new', 637 'res_id': self.id, 638 'context': dict( 639 self.env.context, 640 show_owner=self.picking_type_id.code != 'incoming', 641 show_lots_m2o=self.has_tracking != 'none' and (picking_type_id.use_existing_lots or self.state == 'done' or self.origin_returned_move_id.id), # able to create lots, whatever the value of ` use_create_lots`. 642 show_lots_text=self.has_tracking != 'none' and picking_type_id.use_create_lots and not picking_type_id.use_existing_lots and self.state != 'done' and not self.origin_returned_move_id.id, 643 show_source_location=self.picking_type_id.code != 'incoming', 644 show_destination_location=self.picking_type_id.code != 'outgoing', 645 show_package=not self.location_id.usage == 'supplier', 646 show_reserved_quantity=self.state != 'done' and not self.picking_id.immediate_transfer and self.picking_type_id.code != 'incoming' 647 ), 648 } 649 650 def action_assign_serial_show_details(self): 651 """ On `self.move_line_ids`, assign `lot_name` according to 652 `self.next_serial` before returning `self.action_show_details`. 653 """ 654 self.ensure_one() 655 if not self.next_serial: 656 raise UserError(_("You need to set a Serial Number before generating more.")) 657 self._generate_serial_numbers() 658 return self.action_show_details() 659 660 def action_clear_lines_show_details(self): 661 """ Unlink `self.move_line_ids` before returning `self.action_show_details`. 662 Useful for if a user creates too many SNs by accident via action_assign_serial_show_details 663 since there's no way to undo the action. 664 """ 665 self.ensure_one() 666 if self.picking_type_id.show_reserved: 667 move_lines = self.move_line_ids 668 else: 669 move_lines = self.move_line_nosuggest_ids 670 move_lines.unlink() 671 return self.action_show_details() 672 673 def action_assign_serial(self): 674 """ Opens a wizard to assign SN's name on each move lines. 675 """ 676 self.ensure_one() 677 action = self.env["ir.actions.actions"]._for_xml_id("stock.act_assign_serial_numbers") 678 action['context'] = { 679 'default_product_id': self.product_id.id, 680 'default_move_id': self.id, 681 } 682 return action 683 684 def action_product_forecast_report(self): 685 self.ensure_one() 686 action = self.product_id.action_product_forecast_report() 687 warehouse = self.location_id.get_warehouse() 688 action['context'] = {'warehouse': warehouse.id, } if warehouse else {} 689 return action 690 691 def _do_unreserve(self): 692 moves_to_unreserve = OrderedSet() 693 for move in self: 694 if move.state == 'cancel' or (move.state == 'done' and move.scrapped): 695 # We may have cancelled move in an open picking in a "propagate_cancel" scenario. 696 # We may have done move in an open picking in a scrap scenario. 697 continue 698 elif move.state == 'done': 699 raise UserError(_("You cannot unreserve a stock move that has been set to 'Done'.")) 700 moves_to_unreserve.add(move.id) 701 moves_to_unreserve = self.env['stock.move'].browse(moves_to_unreserve) 702 703 ml_to_update, ml_to_unlink = OrderedSet(), OrderedSet() 704 moves_not_to_recompute = OrderedSet() 705 for ml in moves_to_unreserve.move_line_ids: 706 if ml.qty_done: 707 ml_to_update.add(ml.id) 708 else: 709 ml_to_unlink.add(ml.id) 710 moves_not_to_recompute.add(ml.move_id.id) 711 ml_to_update, ml_to_unlink = self.env['stock.move.line'].browse(ml_to_update), self.env['stock.move.line'].browse(ml_to_unlink) 712 moves_not_to_recompute = self.env['stock.move'].browse(moves_not_to_recompute) 713 714 ml_to_update.write({'product_uom_qty': 0}) 715 ml_to_unlink.unlink() 716 # `write` on `stock.move.line` doesn't call `_recompute_state` (unlike to `unlink`), 717 # so it must be called for each move where no move line has been deleted. 718 (moves_to_unreserve - moves_not_to_recompute)._recompute_state() 719 return True 720 721 def _generate_serial_numbers(self, next_serial_count=False): 722 """ This method will generate `lot_name` from a string (field 723 `next_serial`) and create a move line for each generated `lot_name`. 724 """ 725 self.ensure_one() 726 727 if not next_serial_count: 728 next_serial_count = self.next_serial_count 729 # We look if the serial number contains at least one digit. 730 caught_initial_number = regex_findall("\d+", self.next_serial) 731 if not caught_initial_number: 732 raise UserError(_('The serial number must contain at least one digit.')) 733 # We base the serie on the last number find in the base serial number. 734 initial_number = caught_initial_number[-1] 735 padding = len(initial_number) 736 # We split the serial number to get the prefix and suffix. 737 splitted = regex_split(initial_number, self.next_serial) 738 # initial_number could appear several times in the SN, e.g. BAV023B00001S00001 739 prefix = initial_number.join(splitted[:-1]) 740 suffix = splitted[-1] 741 initial_number = int(initial_number) 742 743 lot_names = [] 744 for i in range(0, next_serial_count): 745 lot_names.append('%s%s%s' % ( 746 prefix, 747 str(initial_number + i).zfill(padding), 748 suffix 749 )) 750 move_lines_commands = self._generate_serial_move_line_commands(lot_names) 751 self.write({'move_line_ids': move_lines_commands}) 752 return True 753 754 def _push_apply(self): 755 for move in self: 756 # if the move is already chained, there is no need to check push rules 757 if move.move_dest_ids: 758 continue 759 # if the move is a returned move, we don't want to check push rules, as returning a returned move is the only decent way 760 # to receive goods without triggering the push rules again (which would duplicate chained operations) 761 domain = [('location_src_id', '=', move.location_dest_id.id), ('action', 'in', ('push', 'pull_push'))] 762 # first priority goes to the preferred routes defined on the move itself (e.g. coming from a SO line) 763 warehouse_id = move.warehouse_id or move.picking_id.picking_type_id.warehouse_id 764 if move.location_dest_id.company_id == self.env.company: 765 rules = self.env['procurement.group']._search_rule(move.route_ids, move.product_id, warehouse_id, domain) 766 else: 767 rules = self.sudo().env['procurement.group']._search_rule(move.route_ids, move.product_id, warehouse_id, domain) 768 # Make sure it is not returning the return 769 if rules and (not move.origin_returned_move_id or move.origin_returned_move_id.location_dest_id.id != rules.location_id.id): 770 rules._run_push(move) 771 772 def _merge_moves_fields(self): 773 """ This method will return a dict of stock move’s values that represent the values of all moves in `self` merged. """ 774 state = self._get_relevant_state_among_moves() 775 origin = '/'.join(set(self.filtered(lambda m: m.origin).mapped('origin'))) 776 return { 777 'product_uom_qty': sum(self.mapped('product_uom_qty')), 778 'date': min(self.mapped('date')) if self.mapped('picking_id').move_type == 'direct' else max(self.mapped('date')), 779 'move_dest_ids': [(4, m.id) for m in self.mapped('move_dest_ids')], 780 'move_orig_ids': [(4, m.id) for m in self.mapped('move_orig_ids')], 781 'state': state, 782 'origin': origin, 783 } 784 785 @api.model 786 def _prepare_merge_moves_distinct_fields(self): 787 return [ 788 'product_id', 'price_unit', 'procure_method', 'location_id', 'location_dest_id', 789 'product_uom', 'restrict_partner_id', 'scrapped', 'origin_returned_move_id', 790 'package_level_id', 'propagate_cancel', 'description_picking', 'date_deadline' 791 ] 792 793 @api.model 794 def _prepare_merge_move_sort_method(self, move): 795 move.ensure_one() 796 797 description_picking = move.description_picking or "" 798 799 return [ 800 move.product_id.id, move.price_unit, move.procure_method, move.location_id, move.location_dest_id, 801 move.product_uom.id, move.restrict_partner_id.id, move.scrapped, move.origin_returned_move_id.id, 802 move.package_level_id.id, move.propagate_cancel, description_picking 803 ] 804 805 def _clean_merged(self): 806 """Cleanup hook used when merging moves""" 807 self.write({'propagate_cancel': False}) 808 809 def _merge_moves(self, merge_into=False): 810 """ This method will, for each move in `self`, go up in their linked picking and try to 811 find in their existing moves a candidate into which we can merge the move. 812 :return: Recordset of moves passed to this method. If some of the passed moves were merged 813 into another existing one, return this one and not the (now unlinked) original. 814 """ 815 distinct_fields = self._prepare_merge_moves_distinct_fields() 816 817 candidate_moves_list = [] 818 if not merge_into: 819 for picking in self.mapped('picking_id'): 820 candidate_moves_list.append(picking.move_lines) 821 else: 822 candidate_moves_list.append(merge_into | self) 823 824 # Move removed after merge 825 moves_to_unlink = self.env['stock.move'] 826 moves_to_merge = [] 827 for candidate_moves in candidate_moves_list: 828 # First step find move to merge. 829 candidate_moves = candidate_moves.with_context(prefetch_fields=False) 830 for k, g in groupby(sorted(candidate_moves, key=self._prepare_merge_move_sort_method), key=itemgetter(*distinct_fields)): 831 moves = self.env['stock.move'].concat(*g).filtered(lambda m: m.state not in ('done', 'cancel', 'draft')) 832 # If we have multiple records we will merge then in a single one. 833 if len(moves) > 1: 834 moves_to_merge.append(moves) 835 836 # second step merge its move lines, initial demand, ... 837 for moves in moves_to_merge: 838 # link all move lines to record 0 (the one we will keep). 839 moves.mapped('move_line_ids').write({'move_id': moves[0].id}) 840 # merge move data 841 moves[0].write(moves._merge_moves_fields()) 842 # update merged moves dicts 843 moves_to_unlink |= moves[1:] 844 845 if moves_to_unlink: 846 # We are using propagate to False in order to not cancel destination moves merged in moves[0] 847 moves_to_unlink._clean_merged() 848 moves_to_unlink._action_cancel() 849 moves_to_unlink.sudo().unlink() 850 return (self | self.env['stock.move'].concat(*moves_to_merge)) - moves_to_unlink 851 852 def _get_relevant_state_among_moves(self): 853 # We sort our moves by importance of state: 854 # ------------- 0 855 # | Assigned | 856 # ------------- 857 # | Waiting | 858 # ------------- 859 # | Partial | 860 # ------------- 861 # | Confirm | 862 # ------------- len-1 863 sort_map = { 864 'assigned': 4, 865 'waiting': 3, 866 'partially_available': 2, 867 'confirmed': 1, 868 } 869 moves_todo = self\ 870 .filtered(lambda move: move.state not in ['cancel', 'done'] and not (move.state == 'assigned' and not move.product_uom_qty))\ 871 .sorted(key=lambda move: (sort_map.get(move.state, 0), move.product_uom_qty)) 872 if not moves_todo: 873 return 'assigned' 874 # The picking should be the same for all moves. 875 if moves_todo[:1].picking_id and moves_todo[:1].picking_id.move_type == 'one': 876 most_important_move = moves_todo[0] 877 if most_important_move.state == 'confirmed': 878 return 'confirmed' if most_important_move.product_uom_qty else 'assigned' 879 elif most_important_move.state == 'partially_available': 880 return 'confirmed' 881 else: 882 return moves_todo[:1].state or 'draft' 883 elif moves_todo[:1].state != 'assigned' and any(move.state in ['assigned', 'partially_available'] for move in moves_todo): 884 return 'partially_available' 885 else: 886 least_important_move = moves_todo[-1:] 887 if least_important_move.state == 'confirmed' and least_important_move.product_uom_qty == 0: 888 return 'assigned' 889 else: 890 return moves_todo[-1:].state or 'draft' 891 892 @api.onchange('product_id') 893 def onchange_product_id(self): 894 product = self.product_id.with_context(lang=self._get_lang()) 895 self.name = product.partner_ref 896 self.product_uom = product.uom_id.id 897 898 @api.onchange('lot_ids') 899 def _onchange_lot_ids(self): 900 quantity_done = sum(ml.product_uom_id._compute_quantity(ml.qty_done, self.product_uom) for ml in self.move_line_ids.filtered(lambda ml: not ml.lot_id and ml.lot_name)) 901 quantity_done += self.product_id.uom_id._compute_quantity(len(self.lot_ids), self.product_uom) 902 self.update({'quantity_done': quantity_done}) 903 used_lots = self.env['stock.move.line'].search([ 904 ('company_id', '=', self.company_id.id), 905 ('product_id', '=', self.product_id.id), 906 ('lot_id', 'in', self.lot_ids.ids), 907 ('move_id', '!=', self._origin.id), 908 ('state', '!=', 'cancel') 909 ]) 910 if used_lots: 911 return { 912 'warning': {'title': _('Warning'), 'message': _('Existing Serial numbers (%s). Please correct the serial numbers encoded.') % ','.join(used_lots.lot_id.mapped('display_name'))} 913 } 914 915 @api.onchange('move_line_ids', 'move_line_nosuggest_ids') 916 def onchange_move_line_ids(self): 917 if not self.picking_type_id.use_create_lots: 918 # This onchange manages the creation of multiple lot name. We don't 919 # need that if the picking type disallows the creation of new lots. 920 return 921 922 breaking_char = '\n' 923 if self.picking_type_id.show_reserved: 924 move_lines = self.move_line_ids 925 else: 926 move_lines = self.move_line_nosuggest_ids 927 928 for move_line in move_lines: 929 # Look if the `lot_name` contains multiple values. 930 if breaking_char in (move_line.lot_name or ''): 931 split_lines = move_line.lot_name.split(breaking_char) 932 split_lines = list(filter(None, split_lines)) 933 move_line.lot_name = split_lines[0] 934 move_lines_commands = self._generate_serial_move_line_commands( 935 split_lines[1:], 936 origin_move_line=move_line, 937 ) 938 if self.picking_type_id.show_reserved: 939 self.update({'move_line_ids': move_lines_commands}) 940 else: 941 self.update({'move_line_nosuggest_ids': move_lines_commands}) 942 existing_lots = self.env['stock.production.lot'].search([ 943 ('company_id', '=', self.company_id.id), 944 ('product_id', '=', self.product_id.id), 945 ('name', 'in', split_lines), 946 ]) 947 if existing_lots: 948 return { 949 'warning': {'title': _('Warning'), 'message': _('Existing Serial Numbers (%s). Please correct the serial numbers encoded.') % ','.join(existing_lots.mapped('display_name'))} 950 } 951 break 952 953 @api.onchange('product_uom') 954 def onchange_product_uom(self): 955 if self.product_uom.factor > self.product_id.uom_id.factor: 956 return { 957 'warning': { 958 'title': "Unsafe unit of measure", 959 'message': _("You are using a unit of measure smaller than the one you are using in " 960 "order to stock your product. This can lead to rounding problem on reserved quantity. " 961 "You should use the smaller unit of measure possible in order to valuate your stock or " 962 "change its rounding precision to a smaller value (example: 0.00001)."), 963 } 964 } 965 966 def _key_assign_picking(self): 967 self.ensure_one() 968 return self.group_id, self.location_id, self.location_dest_id, self.picking_type_id 969 970 def _search_picking_for_assignation(self): 971 self.ensure_one() 972 picking = self.env['stock.picking'].search([ 973 ('group_id', '=', self.group_id.id), 974 ('location_id', '=', self.location_id.id), 975 ('location_dest_id', '=', self.location_dest_id.id), 976 ('picking_type_id', '=', self.picking_type_id.id), 977 ('printed', '=', False), 978 ('immediate_transfer', '=', False), 979 ('state', 'in', ['draft', 'confirmed', 'waiting', 'partially_available', 'assigned'])], limit=1) 980 return picking 981 982 def _assign_picking(self): 983 """ Try to assign the moves to an existing picking that has not been 984 reserved yet and has the same procurement group, locations and picking 985 type (moves should already have them identical). Otherwise, create a new 986 picking to assign them to. """ 987 Picking = self.env['stock.picking'] 988 grouped_moves = groupby(sorted(self, key=lambda m: [f.id for f in m._key_assign_picking()]), key=lambda m: [m._key_assign_picking()]) 989 for group, moves in grouped_moves: 990 moves = self.env['stock.move'].concat(*list(moves)) 991 new_picking = False 992 # Could pass the arguments contained in group but they are the same 993 # for each move that why moves[0] is acceptable 994 picking = moves[0]._search_picking_for_assignation() 995 if picking: 996 if any(picking.partner_id.id != m.partner_id.id or 997 picking.origin != m.origin for m in moves): 998 # If a picking is found, we'll append `move` to its move list and thus its 999 # `partner_id` and `ref` field will refer to multiple records. In this 1000 # case, we chose to wipe them. 1001 picking.write({ 1002 'partner_id': False, 1003 'origin': False, 1004 }) 1005 else: 1006 new_picking = True 1007 picking = Picking.create(moves._get_new_picking_values()) 1008 1009 moves.write({'picking_id': picking.id}) 1010 moves._assign_picking_post_process(new=new_picking) 1011 return True 1012 1013 def _assign_picking_post_process(self, new=False): 1014 pass 1015 1016 def _generate_serial_move_line_commands(self, lot_names, origin_move_line=None): 1017 """Return a list of commands to update the move lines (write on 1018 existing ones or create new ones). 1019 Called when user want to create and assign multiple serial numbers in 1020 one time (using the button/wizard or copy-paste a list in the field). 1021 1022 :param lot_names: A list containing all serial number to assign. 1023 :type lot_names: list 1024 :param origin_move_line: A move line to duplicate the value from, default to None 1025 :type origin_move_line: record of :class:`stock.move.line` 1026 :return: A list of commands to create/update :class:`stock.move.line` 1027 :rtype: list 1028 """ 1029 self.ensure_one() 1030 1031 # Select the right move lines depending of the picking type configuration. 1032 move_lines = self.env['stock.move.line'] 1033 if self.picking_type_id.show_reserved: 1034 move_lines = self.move_line_ids.filtered(lambda ml: not ml.lot_id and not ml.lot_name) 1035 else: 1036 move_lines = self.move_line_nosuggest_ids.filtered(lambda ml: not ml.lot_id and not ml.lot_name) 1037 1038 if origin_move_line: 1039 location_dest = origin_move_line.location_dest_id 1040 else: 1041 location_dest = self.location_dest_id._get_putaway_strategy(self.product_id) 1042 move_line_vals = { 1043 'picking_id': self.picking_id.id, 1044 'location_dest_id': location_dest.id or self.location_dest_id.id, 1045 'location_id': self.location_id.id, 1046 'product_id': self.product_id.id, 1047 'product_uom_id': self.product_id.uom_id.id, 1048 'qty_done': 1, 1049 } 1050 if origin_move_line: 1051 # `owner_id` and `package_id` are taken only in the case we create 1052 # new move lines from an existing move line. Also, updates the 1053 # `qty_done` because it could be usefull for products tracked by lot. 1054 move_line_vals.update({ 1055 'owner_id': origin_move_line.owner_id.id, 1056 'package_id': origin_move_line.package_id.id, 1057 'qty_done': origin_move_line.qty_done or 1, 1058 }) 1059 1060 move_lines_commands = [] 1061 for lot_name in lot_names: 1062 # We write the lot name on an existing move line (if we have still one)... 1063 if move_lines: 1064 move_lines_commands.append((1, move_lines[0].id, { 1065 'lot_name': lot_name, 1066 'qty_done': 1, 1067 })) 1068 move_lines = move_lines[1:] 1069 # ... or create a new move line with the serial name. 1070 else: 1071 move_line_cmd = dict(move_line_vals, lot_name=lot_name) 1072 move_lines_commands.append((0, 0, move_line_cmd)) 1073 return move_lines_commands 1074 1075 def _get_new_picking_values(self): 1076 """ return create values for new picking that will be linked with group 1077 of moves in self. 1078 """ 1079 origins = self.filtered(lambda m: m.origin).mapped('origin') 1080 origins = list(dict.fromkeys(origins)) # create a list of unique items 1081 # Will display source document if any, when multiple different origins 1082 # are found display a maximum of 5 1083 if len(origins) == 0: 1084 origin = False 1085 else: 1086 origin = ','.join(origins[:5]) 1087 if len(origins) > 5: 1088 origin += "..." 1089 partners = self.mapped('partner_id') 1090 partner = len(partners) == 1 and partners.id or False 1091 return { 1092 'origin': origin, 1093 'company_id': self.mapped('company_id').id, 1094 'user_id': False, 1095 'move_type': self.mapped('group_id').move_type or 'direct', 1096 'partner_id': partner, 1097 'picking_type_id': self.mapped('picking_type_id').id, 1098 'location_id': self.mapped('location_id').id, 1099 'location_dest_id': self.mapped('location_dest_id').id, 1100 } 1101 1102 def _should_be_assigned(self): 1103 self.ensure_one() 1104 return bool(not self.picking_id and self.picking_type_id) 1105 1106 def _action_confirm(self, merge=True, merge_into=False): 1107 """ Confirms stock move or put it in waiting if it's linked to another move. 1108 :param: merge: According to this boolean, a newly confirmed move will be merged 1109 in another move of the same picking sharing its characteristics. 1110 """ 1111 move_create_proc = self.env['stock.move'] 1112 move_to_confirm = self.env['stock.move'] 1113 move_waiting = self.env['stock.move'] 1114 1115 to_assign = {} 1116 for move in self: 1117 if move.state != 'draft': 1118 continue 1119 # if the move is preceeded, then it's waiting (if preceeding move is done, then action_assign has been called already and its state is already available) 1120 if move.move_orig_ids: 1121 move_waiting |= move 1122 else: 1123 if move.procure_method == 'make_to_order': 1124 move_create_proc |= move 1125 else: 1126 move_to_confirm |= move 1127 if move._should_be_assigned(): 1128 key = (move.group_id.id, move.location_id.id, move.location_dest_id.id) 1129 if key not in to_assign: 1130 to_assign[key] = self.env['stock.move'] 1131 to_assign[key] |= move 1132 1133 # create procurements for make to order moves 1134 procurement_requests = [] 1135 for move in move_create_proc: 1136 values = move._prepare_procurement_values() 1137 origin = (move.group_id and move.group_id.name or (move.origin or move.picking_id.name or "/")) 1138 procurement_requests.append(self.env['procurement.group'].Procurement( 1139 move.product_id, move.product_uom_qty, move.product_uom, 1140 move.location_id, move.rule_id and move.rule_id.name or "/", 1141 origin, move.company_id, values)) 1142 self.env['procurement.group'].run(procurement_requests, raise_user_error=not self.env.context.get('from_orderpoint')) 1143 1144 move_to_confirm.write({'state': 'confirmed'}) 1145 (move_waiting | move_create_proc).write({'state': 'waiting'}) 1146 1147 # assign picking in batch for all confirmed move that share the same details 1148 for moves in to_assign.values(): 1149 moves._assign_picking() 1150 self._push_apply() 1151 self._check_company() 1152 moves = self 1153 if merge: 1154 moves = self._merge_moves(merge_into=merge_into) 1155 # call `_action_assign` on every confirmed move which location_id bypasses the reservation 1156 moves.filtered(lambda move: not move.picking_id.immediate_transfer and move._should_bypass_reservation() and move.state == 'confirmed')._action_assign() 1157 return moves 1158 1159 def _prepare_procurement_values(self): 1160 """ Prepare specific key for moves or other componenets that will be created from a stock rule 1161 comming from a stock move. This method could be override in order to add other custom key that could 1162 be used in move/po creation. 1163 """ 1164 self.ensure_one() 1165 group_id = self.group_id or False 1166 if self.rule_id: 1167 if self.rule_id.group_propagation_option == 'fixed' and self.rule_id.group_id: 1168 group_id = self.rule_id.group_id 1169 elif self.rule_id.group_propagation_option == 'none': 1170 group_id = False 1171 product_id = self.product_id.with_context(lang=self._get_lang()) 1172 return { 1173 'product_description_variants': self.description_picking and self.description_picking.replace(product_id._get_description(self.picking_type_id), ''), 1174 'date_planned': self.date, 1175 'date_deadline': self.date_deadline, 1176 'move_dest_ids': self, 1177 'group_id': group_id, 1178 'route_ids': self.route_ids, 1179 'warehouse_id': self.warehouse_id or self.picking_id.picking_type_id.warehouse_id or self.picking_type_id.warehouse_id, 1180 'priority': self.priority, 1181 'orderpoint_id': self.orderpoint_id, 1182 } 1183 1184 def _prepare_move_line_vals(self, quantity=None, reserved_quant=None): 1185 self.ensure_one() 1186 # apply putaway 1187 location_dest_id = self.location_dest_id._get_putaway_strategy(self.product_id).id or self.location_dest_id.id 1188 vals = { 1189 'move_id': self.id, 1190 'product_id': self.product_id.id, 1191 'product_uom_id': self.product_uom.id, 1192 'location_id': self.location_id.id, 1193 'location_dest_id': location_dest_id, 1194 'picking_id': self.picking_id.id, 1195 'company_id': self.company_id.id, 1196 } 1197 if quantity: 1198 rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure') 1199 uom_quantity = self.product_id.uom_id._compute_quantity(quantity, self.product_uom, rounding_method='HALF-UP') 1200 uom_quantity = float_round(uom_quantity, precision_digits=rounding) 1201 uom_quantity_back_to_product_uom = self.product_uom._compute_quantity(uom_quantity, self.product_id.uom_id, rounding_method='HALF-UP') 1202 if float_compare(quantity, uom_quantity_back_to_product_uom, precision_digits=rounding) == 0: 1203 vals = dict(vals, product_uom_qty=uom_quantity) 1204 else: 1205 vals = dict(vals, product_uom_qty=quantity, product_uom_id=self.product_id.uom_id.id) 1206 if reserved_quant: 1207 vals = dict( 1208 vals, 1209 location_id=reserved_quant.location_id.id, 1210 lot_id=reserved_quant.lot_id.id or False, 1211 package_id=reserved_quant.package_id.id or False, 1212 owner_id =reserved_quant.owner_id.id or False, 1213 ) 1214 return vals 1215 1216 def _update_reserved_quantity(self, need, available_quantity, location_id, lot_id=None, package_id=None, owner_id=None, strict=True): 1217 """ Create or update move lines. 1218 """ 1219 self.ensure_one() 1220 1221 if not lot_id: 1222 lot_id = self.env['stock.production.lot'] 1223 if not package_id: 1224 package_id = self.env['stock.quant.package'] 1225 if not owner_id: 1226 owner_id = self.env['res.partner'] 1227 1228 taken_quantity = min(available_quantity, need) 1229 1230 # `taken_quantity` is in the quants unit of measure. There's a possibility that the move's 1231 # unit of measure won't be respected if we blindly reserve this quantity, a common usecase 1232 # is if the move's unit of measure's rounding does not allow fractional reservation. We chose 1233 # to convert `taken_quantity` to the move's unit of measure with a down rounding method and 1234 # then get it back in the quants unit of measure with an half-up rounding_method. This 1235 # way, we'll never reserve more than allowed. We do not apply this logic if 1236 # `available_quantity` is brought by a chained move line. In this case, `_prepare_move_line_vals` 1237 # will take care of changing the UOM to the UOM of the product. 1238 if not strict and self.product_id.uom_id != self.product_uom: 1239 taken_quantity_move_uom = self.product_id.uom_id._compute_quantity(taken_quantity, self.product_uom, rounding_method='DOWN') 1240 taken_quantity = self.product_uom._compute_quantity(taken_quantity_move_uom, self.product_id.uom_id, rounding_method='HALF-UP') 1241 1242 quants = [] 1243 rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure') 1244 1245 if self.product_id.tracking == 'serial': 1246 if float_compare(taken_quantity, int(taken_quantity), precision_digits=rounding) != 0: 1247 taken_quantity = 0 1248 1249 try: 1250 with self.env.cr.savepoint(): 1251 if not float_is_zero(taken_quantity, precision_rounding=self.product_id.uom_id.rounding): 1252 quants = self.env['stock.quant']._update_reserved_quantity( 1253 self.product_id, location_id, taken_quantity, lot_id=lot_id, 1254 package_id=package_id, owner_id=owner_id, strict=strict 1255 ) 1256 except UserError: 1257 taken_quantity = 0 1258 1259 # Find a candidate move line to update or create a new one. 1260 for reserved_quant, quantity in quants: 1261 to_update = self.move_line_ids.filtered(lambda ml: ml._reservation_is_updatable(quantity, reserved_quant)) 1262 if to_update: 1263 uom_quantity = self.product_id.uom_id._compute_quantity(quantity, to_update[0].product_uom_id, rounding_method='HALF-UP') 1264 uom_quantity = float_round(uom_quantity, precision_digits=rounding) 1265 uom_quantity_back_to_product_uom = to_update[0].product_uom_id._compute_quantity(uom_quantity, self.product_id.uom_id, rounding_method='HALF-UP') 1266 if to_update and float_compare(quantity, uom_quantity_back_to_product_uom, precision_digits=rounding) == 0: 1267 to_update[0].with_context(bypass_reservation_update=True).product_uom_qty += uom_quantity 1268 else: 1269 if self.product_id.tracking == 'serial': 1270 for i in range(0, int(quantity)): 1271 self.env['stock.move.line'].create(self._prepare_move_line_vals(quantity=1, reserved_quant=reserved_quant)) 1272 else: 1273 self.env['stock.move.line'].create(self._prepare_move_line_vals(quantity=quantity, reserved_quant=reserved_quant)) 1274 return taken_quantity 1275 1276 def _should_bypass_reservation(self): 1277 self.ensure_one() 1278 return self.location_id.should_bypass_reservation() or self.product_id.type != 'product' 1279 1280 # necessary hook to be able to override move reservation to a restrict lot, owner, pack, location... 1281 def _get_available_quantity(self, location_id, lot_id=None, package_id=None, owner_id=None, strict=False, allow_negative=False): 1282 self.ensure_one() 1283 return self.env['stock.quant']._get_available_quantity(self.product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict, allow_negative=allow_negative) 1284 1285 def _action_assign(self): 1286 """ Reserve stock moves by creating their stock move lines. A stock move is 1287 considered reserved once the sum of `product_qty` for all its move lines is 1288 equal to its `product_qty`. If it is less, the stock move is considered 1289 partially available. 1290 """ 1291 StockMove = self.env['stock.move'] 1292 assigned_moves_ids = OrderedSet() 1293 partially_available_moves_ids = OrderedSet() 1294 # Read the `reserved_availability` field of the moves out of the loop to prevent unwanted 1295 # cache invalidation when actually reserving the move. 1296 reserved_availability = {move: move.reserved_availability for move in self} 1297 roundings = {move: move.product_id.uom_id.rounding for move in self} 1298 move_line_vals_list = [] 1299 for move in self.filtered(lambda m: m.state in ['confirmed', 'waiting', 'partially_available']): 1300 rounding = roundings[move] 1301 missing_reserved_uom_quantity = move.product_uom_qty - reserved_availability[move] 1302 missing_reserved_quantity = move.product_uom._compute_quantity(missing_reserved_uom_quantity, move.product_id.uom_id, rounding_method='HALF-UP') 1303 if move._should_bypass_reservation(): 1304 # create the move line(s) but do not impact quants 1305 if move.product_id.tracking == 'serial' and (move.picking_type_id.use_create_lots or move.picking_type_id.use_existing_lots): 1306 for i in range(0, int(missing_reserved_quantity)): 1307 move_line_vals_list.append(move._prepare_move_line_vals(quantity=1)) 1308 else: 1309 to_update = move.move_line_ids.filtered(lambda ml: ml.product_uom_id == move.product_uom and 1310 ml.location_id == move.location_id and 1311 ml.location_dest_id == move.location_dest_id and 1312 ml.picking_id == move.picking_id and 1313 not ml.lot_id and 1314 not ml.package_id and 1315 not ml.owner_id) 1316 if to_update: 1317 to_update[0].product_uom_qty += missing_reserved_uom_quantity 1318 else: 1319 move_line_vals_list.append(move._prepare_move_line_vals(quantity=missing_reserved_quantity)) 1320 assigned_moves_ids.add(move.id) 1321 else: 1322 if float_is_zero(move.product_uom_qty, precision_rounding=move.product_uom.rounding): 1323 assigned_moves_ids.add(move.id) 1324 elif not move.move_orig_ids: 1325 if move.procure_method == 'make_to_order': 1326 continue 1327 # If we don't need any quantity, consider the move assigned. 1328 need = missing_reserved_quantity 1329 if float_is_zero(need, precision_rounding=rounding): 1330 assigned_moves_ids.add(move.id) 1331 continue 1332 # Reserve new quants and create move lines accordingly. 1333 forced_package_id = move.package_level_id.package_id or None 1334 available_quantity = move._get_available_quantity(move.location_id, package_id=forced_package_id) 1335 if available_quantity <= 0: 1336 continue 1337 taken_quantity = move._update_reserved_quantity(need, available_quantity, move.location_id, package_id=forced_package_id, strict=False) 1338 if float_is_zero(taken_quantity, precision_rounding=rounding): 1339 continue 1340 if float_compare(need, taken_quantity, precision_rounding=rounding) == 0: 1341 assigned_moves_ids.add(move.id) 1342 else: 1343 partially_available_moves_ids.add(move.id) 1344 else: 1345 # Check what our parents brought and what our siblings took in order to 1346 # determine what we can distribute. 1347 # `qty_done` is in `ml.product_uom_id` and, as we will later increase 1348 # the reserved quantity on the quants, convert it here in 1349 # `product_id.uom_id` (the UOM of the quants is the UOM of the product). 1350 move_lines_in = move.move_orig_ids.filtered(lambda m: m.state == 'done').mapped('move_line_ids') 1351 keys_in_groupby = ['location_dest_id', 'lot_id', 'result_package_id', 'owner_id'] 1352 1353 def _keys_in_sorted(ml): 1354 return (ml.location_dest_id.id, ml.lot_id.id, ml.result_package_id.id, ml.owner_id.id) 1355 1356 grouped_move_lines_in = {} 1357 for k, g in groupby(sorted(move_lines_in, key=_keys_in_sorted), key=itemgetter(*keys_in_groupby)): 1358 qty_done = 0 1359 for ml in g: 1360 qty_done += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id) 1361 grouped_move_lines_in[k] = qty_done 1362 move_lines_out_done = (move.move_orig_ids.mapped('move_dest_ids') - move)\ 1363 .filtered(lambda m: m.state in ['done'])\ 1364 .mapped('move_line_ids') 1365 # As we defer the write on the stock.move's state at the end of the loop, there 1366 # could be moves to consider in what our siblings already took. 1367 moves_out_siblings = move.move_orig_ids.mapped('move_dest_ids') - move 1368 moves_out_siblings_to_consider = moves_out_siblings & (StockMove.browse(assigned_moves_ids) + StockMove.browse(partially_available_moves_ids)) 1369 reserved_moves_out_siblings = moves_out_siblings.filtered(lambda m: m.state in ['partially_available', 'assigned']) 1370 move_lines_out_reserved = (reserved_moves_out_siblings | moves_out_siblings_to_consider).mapped('move_line_ids') 1371 keys_out_groupby = ['location_id', 'lot_id', 'package_id', 'owner_id'] 1372 1373 def _keys_out_sorted(ml): 1374 return (ml.location_id.id, ml.lot_id.id, ml.package_id.id, ml.owner_id.id) 1375 1376 grouped_move_lines_out = {} 1377 for k, g in groupby(sorted(move_lines_out_done, key=_keys_out_sorted), key=itemgetter(*keys_out_groupby)): 1378 qty_done = 0 1379 for ml in g: 1380 qty_done += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id) 1381 grouped_move_lines_out[k] = qty_done 1382 for k, g in groupby(sorted(move_lines_out_reserved, key=_keys_out_sorted), key=itemgetter(*keys_out_groupby)): 1383 grouped_move_lines_out[k] = sum(self.env['stock.move.line'].concat(*list(g)).mapped('product_qty')) 1384 available_move_lines = {key: grouped_move_lines_in[key] - grouped_move_lines_out.get(key, 0) for key in grouped_move_lines_in.keys()} 1385 # pop key if the quantity available amount to 0 1386 available_move_lines = dict((k, v) for k, v in available_move_lines.items() if v) 1387 1388 if not available_move_lines: 1389 continue 1390 for move_line in move.move_line_ids.filtered(lambda m: m.product_qty): 1391 if available_move_lines.get((move_line.location_id, move_line.lot_id, move_line.result_package_id, move_line.owner_id)): 1392 available_move_lines[(move_line.location_id, move_line.lot_id, move_line.result_package_id, move_line.owner_id)] -= move_line.product_qty 1393 for (location_id, lot_id, package_id, owner_id), quantity in available_move_lines.items(): 1394 need = move.product_qty - sum(move.move_line_ids.mapped('product_qty')) 1395 # `quantity` is what is brought by chained done move lines. We double check 1396 # here this quantity is available on the quants themselves. If not, this 1397 # could be the result of an inventory adjustment that removed totally of 1398 # partially `quantity`. When this happens, we chose to reserve the maximum 1399 # still available. This situation could not happen on MTS move, because in 1400 # this case `quantity` is directly the quantity on the quants themselves. 1401 available_quantity = move._get_available_quantity(location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=True) 1402 if float_is_zero(available_quantity, precision_rounding=rounding): 1403 continue 1404 taken_quantity = move._update_reserved_quantity(need, min(quantity, available_quantity), location_id, lot_id, package_id, owner_id) 1405 if float_is_zero(taken_quantity, precision_rounding=rounding): 1406 continue 1407 if float_is_zero(need - taken_quantity, precision_rounding=rounding): 1408 assigned_moves_ids.add(move.id) 1409 break 1410 partially_available_moves_ids.add(move.id) 1411 if move.product_id.tracking == 'serial': 1412 move.next_serial_count = move.product_uom_qty 1413 1414 self.env['stock.move.line'].create(move_line_vals_list) 1415 StockMove.browse(partially_available_moves_ids).write({'state': 'partially_available'}) 1416 StockMove.browse(assigned_moves_ids).write({'state': 'assigned'}) 1417 self.mapped('picking_id')._check_entire_pack() 1418 1419 def _action_cancel(self): 1420 if any(move.state == 'done' and not move.scrapped for move in self): 1421 raise UserError(_('You cannot cancel a stock move that has been set to \'Done\'. Create a return in order to reverse the moves which took place.')) 1422 moves_to_cancel = self.filtered(lambda m: m.state != 'cancel') 1423 # self cannot contain moves that are either cancelled or done, therefore we can safely 1424 # unlink all associated move_line_ids 1425 moves_to_cancel._do_unreserve() 1426 1427 for move in moves_to_cancel: 1428 siblings_states = (move.move_dest_ids.mapped('move_orig_ids') - move).mapped('state') 1429 if move.propagate_cancel: 1430 # only cancel the next move if all my siblings are also cancelled 1431 if all(state == 'cancel' for state in siblings_states): 1432 move.move_dest_ids.filtered(lambda m: m.state != 'done')._action_cancel() 1433 else: 1434 if all(state in ('done', 'cancel') for state in siblings_states): 1435 move.move_dest_ids.write({'procure_method': 'make_to_stock'}) 1436 move.move_dest_ids.write({'move_orig_ids': [(3, move.id, 0)]}) 1437 self.write({ 1438 'state': 'cancel', 1439 'move_orig_ids': [(5, 0, 0)], 1440 'procure_method': 'make_to_stock', 1441 }) 1442 return True 1443 1444 def _prepare_extra_move_vals(self, qty): 1445 vals = { 1446 'procure_method': 'make_to_stock', 1447 'origin_returned_move_id': self.origin_returned_move_id.id, 1448 'product_uom_qty': qty, 1449 'picking_id': self.picking_id.id, 1450 'price_unit': self.price_unit, 1451 } 1452 return vals 1453 1454 def _create_extra_move(self): 1455 """ If the quantity done on a move exceeds its quantity todo, this method will create an 1456 extra move attached to a (potentially split) move line. If the previous condition is not 1457 met, it'll return an empty recordset. 1458 1459 The rationale for the creation of an extra move is the application of a potential push 1460 rule that will handle the extra quantities. 1461 """ 1462 extra_move = self 1463 rounding = self.product_uom.rounding 1464 # moves created after the picking is assigned do not have `product_uom_qty`, but we shouldn't create extra moves for them 1465 if float_compare(self.quantity_done, self.product_uom_qty, precision_rounding=rounding) > 0: 1466 # create the extra moves 1467 extra_move_quantity = float_round( 1468 self.quantity_done - self.product_uom_qty, 1469 precision_rounding=rounding, 1470 rounding_method='HALF-UP') 1471 extra_move_vals = self._prepare_extra_move_vals(extra_move_quantity) 1472 extra_move = self.copy(default=extra_move_vals) 1473 1474 merge_into_self = all(self[field] == extra_move[field] for field in self._prepare_merge_moves_distinct_fields()) 1475 1476 if merge_into_self and extra_move.picking_id: 1477 extra_move = extra_move._action_confirm(merge_into=self) 1478 return extra_move 1479 else: 1480 extra_move = extra_move._action_confirm() 1481 1482 # link it to some move lines. We don't need to do it for move since they should be merged. 1483 if not merge_into_self or not extra_move.picking_id: 1484 for move_line in self.move_line_ids.filtered(lambda ml: ml.qty_done): 1485 if float_compare(move_line.qty_done, extra_move_quantity, precision_rounding=rounding) <= 0: 1486 # move this move line to our extra move 1487 move_line.move_id = extra_move.id 1488 extra_move_quantity -= move_line.qty_done 1489 else: 1490 # split this move line and assign the new part to our extra move 1491 quantity_split = float_round( 1492 move_line.qty_done - extra_move_quantity, 1493 precision_rounding=self.product_uom.rounding, 1494 rounding_method='UP') 1495 move_line.qty_done = quantity_split 1496 move_line.copy(default={'move_id': extra_move.id, 'qty_done': extra_move_quantity, 'product_uom_qty': 0}) 1497 extra_move_quantity -= extra_move_quantity 1498 if extra_move_quantity == 0.0: 1499 break 1500 return extra_move | self 1501 1502 def _action_done(self, cancel_backorder=False): 1503 self.filtered(lambda move: move.state == 'draft')._action_confirm() # MRP allows scrapping draft moves 1504 moves = self.exists().filtered(lambda x: x.state not in ('done', 'cancel')) 1505 moves_todo = self.env['stock.move'] 1506 1507 # Cancel moves where necessary ; we should do it before creating the extra moves because 1508 # this operation could trigger a merge of moves. 1509 for move in moves: 1510 if move.quantity_done <= 0: 1511 if float_compare(move.product_uom_qty, 0.0, precision_rounding=move.product_uom.rounding) == 0 or cancel_backorder: 1512 move._action_cancel() 1513 1514 # Create extra moves where necessary 1515 for move in moves: 1516 if move.state == 'cancel' or move.quantity_done <= 0: 1517 continue 1518 1519 moves_todo |= move._create_extra_move() 1520 1521 moves_todo._check_company() 1522 # Split moves where necessary and move quants 1523 backorder_moves_vals = [] 1524 for move in moves_todo: 1525 # To know whether we need to create a backorder or not, round to the general product's 1526 # decimal precision and not the product's UOM. 1527 rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure') 1528 if float_compare(move.quantity_done, move.product_uom_qty, precision_digits=rounding) < 0: 1529 # Need to do some kind of conversion here 1530 qty_split = move.product_uom._compute_quantity(move.product_uom_qty - move.quantity_done, move.product_id.uom_id, rounding_method='HALF-UP') 1531 new_move_vals = move._split(qty_split) 1532 backorder_moves_vals += new_move_vals 1533 backorder_moves = self.env['stock.move'].create(backorder_moves_vals) 1534 backorder_moves._action_confirm(merge=False) 1535 if cancel_backorder: 1536 backorder_moves.with_context(moves_todo=moves_todo)._action_cancel() 1537 moves_todo.mapped('move_line_ids').sorted()._action_done() 1538 # Check the consistency of the result packages; there should be an unique location across 1539 # the contained quants. 1540 for result_package in moves_todo\ 1541 .mapped('move_line_ids.result_package_id')\ 1542 .filtered(lambda p: p.quant_ids and len(p.quant_ids) > 1): 1543 if len(result_package.quant_ids.filtered(lambda q: not float_is_zero(abs(q.quantity) + abs(q.reserved_quantity), precision_rounding=q.product_uom_id.rounding)).mapped('location_id')) > 1: 1544 raise UserError(_('You cannot move the same package content more than once in the same transfer or split the same package into two location.')) 1545 picking = moves_todo.mapped('picking_id') 1546 moves_todo.write({'state': 'done', 'date': fields.Datetime.now()}) 1547 1548 move_dests_per_company = defaultdict(lambda: self.env['stock.move']) 1549 for move_dest in moves_todo.move_dest_ids: 1550 move_dests_per_company[move_dest.company_id.id] |= move_dest 1551 for company_id, move_dests in move_dests_per_company.items(): 1552 move_dests.sudo().with_company(company_id)._action_assign() 1553 1554 # We don't want to create back order for scrap moves 1555 # Replace by a kwarg in master 1556 if self.env.context.get('is_scrap'): 1557 return moves_todo 1558 1559 if picking and not cancel_backorder: 1560 picking._create_backorder() 1561 return moves_todo 1562 1563 def unlink(self): 1564 if any(move.state not in ('draft', 'cancel') for move in self): 1565 raise UserError(_('You can only delete draft moves.')) 1566 # With the non plannified picking, draft moves could have some move lines. 1567 self.with_context(prefetch_fields=False).mapped('move_line_ids').unlink() 1568 return super(StockMove, self).unlink() 1569 1570 def _prepare_move_split_vals(self, qty): 1571 vals = { 1572 'product_uom_qty': qty, 1573 'procure_method': 'make_to_stock', 1574 'move_dest_ids': [(4, x.id) for x in self.move_dest_ids if x.state not in ('done', 'cancel')], 1575 'move_orig_ids': [(4, x.id) for x in self.move_orig_ids], 1576 'origin_returned_move_id': self.origin_returned_move_id.id, 1577 'price_unit': self.price_unit, 1578 } 1579 if self.env.context.get('force_split_uom_id'): 1580 vals['product_uom'] = self.env.context['force_split_uom_id'] 1581 return vals 1582 1583 def _split(self, qty, restrict_partner_id=False): 1584 """ Splits `self` quantity and return values for a new moves to be created afterwards 1585 1586 :param qty: float. quantity to split (given in product UoM) 1587 :param restrict_partner_id: optional partner that can be given in order to force the new move to restrict its choice of quants to the ones belonging to this partner. 1588 :returns: list of dict. stock move values """ 1589 self.ensure_one() 1590 if self.state in ('done', 'cancel'): 1591 raise UserError(_('You cannot split a stock move that has been set to \'Done\'.')) 1592 elif self.state == 'draft': 1593 # we restrict the split of a draft move because if not confirmed yet, it may be replaced by several other moves in 1594 # case of phantom bom (with mrp module). And we don't want to deal with this complexity by copying the product that will explode. 1595 raise UserError(_('You cannot split a draft move. It needs to be confirmed first.')) 1596 if float_is_zero(qty, precision_rounding=self.product_id.uom_id.rounding) or self.product_qty <= qty: 1597 return [] 1598 1599 decimal_precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') 1600 1601 # `qty` passed as argument is the quantity to backorder and is always expressed in the 1602 # quants UOM. If we're able to convert back and forth this quantity in the move's and the 1603 # quants UOM, the backordered move can keep the UOM of the move. Else, we'll create is in 1604 # the UOM of the quants. 1605 uom_qty = self.product_id.uom_id._compute_quantity(qty, self.product_uom, rounding_method='HALF-UP') 1606 if float_compare(qty, self.product_uom._compute_quantity(uom_qty, self.product_id.uom_id, rounding_method='HALF-UP'), precision_digits=decimal_precision) == 0: 1607 defaults = self._prepare_move_split_vals(uom_qty) 1608 else: 1609 defaults = self.with_context(force_split_uom_id=self.product_id.uom_id.id)._prepare_move_split_vals(qty) 1610 1611 if restrict_partner_id: 1612 defaults['restrict_partner_id'] = restrict_partner_id 1613 1614 # TDE CLEANME: remove context key + add as parameter 1615 if self.env.context.get('source_location_id'): 1616 defaults['location_id'] = self.env.context['source_location_id'] 1617 new_move_vals = self.with_context(rounding_method='HALF-UP').copy_data(defaults) 1618 1619 # Update the original `product_qty` of the move. Use the general product's decimal 1620 # precision and not the move's UOM to handle case where the `quantity_done` is not 1621 # compatible with the move's UOM. 1622 new_product_qty = self.product_id.uom_id._compute_quantity(self.product_qty - qty, self.product_uom, round=False) 1623 new_product_qty = float_round(new_product_qty, precision_digits=self.env['decimal.precision'].precision_get('Product Unit of Measure')) 1624 self.with_context(do_not_unreserve=True, rounding_method='HALF-UP').write({'product_uom_qty': new_product_qty}) 1625 return new_move_vals 1626 1627 def _recompute_state(self): 1628 moves_state_to_write = defaultdict(set) 1629 for move in self: 1630 if move.state in ('cancel', 'done', 'draft'): 1631 continue 1632 elif move.reserved_availability == move.product_uom_qty: 1633 moves_state_to_write['assigned'].add(move.id) 1634 elif move.reserved_availability and move.reserved_availability <= move.product_uom_qty: 1635 moves_state_to_write['partially_available'].add(move.id) 1636 elif move.procure_method == 'make_to_order' and not move.move_orig_ids: 1637 moves_state_to_write['waiting'].add(move.id) 1638 elif move.move_orig_ids and any(orig.state not in ('done', 'cancel') for orig in move.move_orig_ids): 1639 moves_state_to_write['waiting'].add(move.id) 1640 else: 1641 moves_state_to_write['confirmed'].add(move.id) 1642 for state, moves_ids in moves_state_to_write.items(): 1643 self.browse(moves_ids).write({'state': state}) 1644 1645 @api.model 1646 def _consuming_picking_types(self): 1647 return ['outgoing'] 1648 1649 def _get_lang(self): 1650 """Determine language to use for translated description""" 1651 return self.picking_id.partner_id.lang or self.partner_id.lang or self.env.user.lang 1652 1653 def _get_source_document(self): 1654 """ Return the move's document, used by `report.stock.report_product_product_replenishment` 1655 and must be overrided to add more document type in the report. 1656 """ 1657 self.ensure_one() 1658 return self.picking_id or False 1659 1660 def _get_upstream_documents_and_responsibles(self, visited): 1661 if self.move_orig_ids and any(m.state not in ('done', 'cancel') for m in self.move_orig_ids): 1662 result = set() 1663 visited |= self 1664 for move in self.move_orig_ids: 1665 if move.state not in ('done', 'cancel'): 1666 for document, responsible, visited in move._get_upstream_documents_and_responsibles(visited): 1667 result.add((document, responsible, visited)) 1668 return result 1669 else: 1670 return [(self.picking_id, self.product_id.responsible_id, visited)] 1671 1672 def _set_quantity_done_prepare_vals(self, qty): 1673 res = [] 1674 for ml in self.move_line_ids: 1675 ml_qty = ml.product_uom_qty - ml.qty_done 1676 if float_compare(ml_qty, 0, precision_rounding=ml.product_uom_id.rounding) <= 0: 1677 continue 1678 # Convert move line qty into move uom 1679 if ml.product_uom_id != self.product_uom: 1680 ml_qty = ml.product_uom_id._compute_quantity(ml_qty, self.product_uom, round=False) 1681 1682 taken_qty = min(qty, ml_qty) 1683 # Convert taken qty into move line uom 1684 if ml.product_uom_id != self.product_uom: 1685 taken_qty = self.product_uom._compute_quantity(ml_qty, ml.product_uom_id, round=False) 1686 1687 # Assign qty_done and explicitly round to make sure there is no inconsistency between 1688 # ml.qty_done and qty. 1689 taken_qty = float_round(taken_qty, precision_rounding=ml.product_uom_id.rounding) 1690 res.append((1, ml.id, {'qty_done': ml.qty_done + taken_qty})) 1691 if ml.product_uom_id != self.product_uom: 1692 taken_qty = ml.product_uom_id._compute_quantity(ml_qty, self.product_uom, round=False) 1693 qty -= taken_qty 1694 1695 if float_compare(qty, 0.0, precision_rounding=self.product_uom.rounding) <= 0: 1696 break 1697 1698 for ml in self.move_line_ids: 1699 if float_is_zero(ml.product_uom_qty, precision_rounding=ml.product_uom_id.rounding) and float_is_zero(ml.qty_done, precision_rounding=ml.product_uom_id.rounding): 1700 res.append((2, ml.id)) 1701 1702 if float_compare(qty, 0.0, precision_rounding=self.product_uom.rounding) > 0: 1703 if self.product_id.tracking != 'serial': 1704 vals = self._prepare_move_line_vals(quantity=0) 1705 vals['qty_done'] = qty 1706 res.append((0, 0, vals)) 1707 else: 1708 uom_qty = self.product_uom._compute_quantity(qty, self.product_id.uom_id) 1709 for i in range(0, int(uom_qty)): 1710 vals = self._prepare_move_line_vals(quantity=0) 1711 vals['qty_done'] = 1 1712 vals['product_uom_id'] = self.product_id.uom_id.id 1713 res.append((0, 0, vals)) 1714 return res 1715 1716 def _set_quantity_done(self, qty): 1717 """ 1718 Set the given quantity as quantity done on the move through the move lines. The method is 1719 able to handle move lines with a different UoM than the move (but honestly, this would be 1720 looking for trouble...). 1721 @param qty: quantity in the UoM of move.product_uom 1722 """ 1723 self.move_line_ids = self._set_quantity_done_prepare_vals(qty) 1724 1725 def _adjust_procure_method(self): 1726 """ This method will try to apply the procure method MTO on some moves if 1727 a compatible MTO route is found. Else the procure method will be set to MTS 1728 """ 1729 # Prepare the MTSO variables. They are needed since MTSO moves are handled separately. 1730 # We need 2 dicts: 1731 # - needed quantity per location per product 1732 # - forecasted quantity per location per product 1733 mtso_products_by_locations = defaultdict(list) 1734 mtso_needed_qties_by_loc = defaultdict(dict) 1735 mtso_free_qties_by_loc = {} 1736 mtso_moves = self.env['stock.move'] 1737 1738 for move in self: 1739 product_id = move.product_id 1740 domain = [ 1741 ('location_src_id', '=', move.location_id.id), 1742 ('location_id', '=', move.location_dest_id.id), 1743 ('action', '!=', 'push') 1744 ] 1745 rules = self.env['procurement.group']._search_rule(False, product_id, move.warehouse_id, domain) 1746 if rules: 1747 if rules.procure_method in ['make_to_order', 'make_to_stock']: 1748 move.procure_method = rules.procure_method 1749 else: 1750 # Get the needed quantity for the `mts_else_mto` moves. 1751 mtso_needed_qties_by_loc[rules.location_src_id].setdefault(product_id.id, 0) 1752 mtso_needed_qties_by_loc[rules.location_src_id][product_id.id] += move.product_qty 1753 1754 # This allow us to get the forecasted quantity in batch later on 1755 mtso_products_by_locations[rules.location_src_id].append(product_id.id) 1756 mtso_moves |= move 1757 else: 1758 move.procure_method = 'make_to_stock' 1759 1760 # Get the forecasted quantity for the `mts_else_mto` moves. 1761 for location, product_ids in mtso_products_by_locations.items(): 1762 products = self.env['product.product'].browse(product_ids).with_context(location=location.id) 1763 mtso_free_qties_by_loc[location] = {product.id: product.free_qty for product in products} 1764 1765 # Now that we have the needed and forecasted quantity per location and per product, we can 1766 # choose whether the mtso_moves need to be MTO or MTS. 1767 for move in mtso_moves: 1768 needed_qty = move.product_qty 1769 forecasted_qty = mtso_free_qties_by_loc[move.location_id][move.product_id.id] 1770 if float_compare(needed_qty, forecasted_qty, precision_rounding=product_id.uom_id.rounding) <= 0: 1771 move.procure_method = 'make_to_stock' 1772 mtso_free_qties_by_loc[move.location_id][move.product_id.id] -= needed_qty 1773 else: 1774 move.procure_method = 'make_to_order' 1775 1776 def _show_details_in_draft(self): 1777 self.ensure_one() 1778 return self.state != 'draft' or (self.picking_id.immediate_transfer and self.state == 'draft') 1779 1780 def _trigger_scheduler(self): 1781 """ Check for auto-triggered orderpoints and trigger them. """ 1782 if not self or self.env['ir.config_parameter'].sudo().get_param('stock.no_auto_scheduler'): 1783 return 1784 1785 orderpoints_by_company = defaultdict(lambda: self.env['stock.warehouse.orderpoint']) 1786 for move in self: 1787 orderpoint = self.env['stock.warehouse.orderpoint'].search([ 1788 ('product_id', '=', move.product_id.id), 1789 ('trigger', '=', 'auto'), 1790 ('location_id', 'parent_of', move.location_id.id), 1791 ('company_id', '=', move.company_id.id) 1792 ], limit=1) 1793 if orderpoint: 1794 orderpoints_by_company[orderpoint.company_id] |= orderpoint 1795 for company, orderpoints in orderpoints_by_company.items(): 1796 orderpoints._procure_orderpoint_confirm(company_id=company, raise_user_error=False) 1797 1798 def _trigger_assign(self): 1799 """ Check for and trigger action_assign for confirmed/partially_available moves related to done moves. 1800 Disable auto reservation if user configured to do so. 1801 """ 1802 if not self or self.env['ir.config_parameter'].sudo().get_param('stock.picking_no_auto_reserve'): 1803 return 1804 1805 domains = [] 1806 for move in self: 1807 domains.append([('product_id', '=', move.product_id.id), ('location_id', '=', move.location_dest_id.id)]) 1808 static_domain = [('state', 'in', ['confirmed', 'partially_available']), ('procure_method', '=', 'make_to_stock')] 1809 moves_to_reserve = self.env['stock.move'].search(expression.AND([static_domain, expression.OR(domains)])) 1810 moves_to_reserve._action_assign() 1811