1# -*- coding: utf-8 -*- 2# Part of Odoo. See LICENSE file for full copyright and licensing details. 3 4import json 5import logging 6from datetime import datetime, timedelta 7from collections import defaultdict 8 9from odoo import api, fields, models, _ 10from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, float_compare, float_round 11from odoo.tools.float_utils import float_repr 12from odoo.tools.misc import format_date 13from odoo.exceptions import UserError 14 15 16_logger = logging.getLogger(__name__) 17 18 19class SaleOrder(models.Model): 20 _inherit = "sale.order" 21 22 @api.model 23 def _default_warehouse_id(self): 24 # !!! Any change to the default value may have to be repercuted 25 # on _init_column() below. 26 return self.env.user._get_default_warehouse_id() 27 28 incoterm = fields.Many2one( 29 'account.incoterms', 'Incoterm', 30 help="International Commercial Terms are a series of predefined commercial terms used in international transactions.") 31 picking_policy = fields.Selection([ 32 ('direct', 'As soon as possible'), 33 ('one', 'When all products are ready')], 34 string='Shipping Policy', required=True, readonly=True, default='direct', 35 states={'draft': [('readonly', False)], 'sent': [('readonly', False)]} 36 ,help="If you deliver all products at once, the delivery order will be scheduled based on the greatest " 37 "product lead time. Otherwise, it will be based on the shortest.") 38 warehouse_id = fields.Many2one( 39 'stock.warehouse', string='Warehouse', 40 required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, 41 default=_default_warehouse_id, check_company=True) 42 picking_ids = fields.One2many('stock.picking', 'sale_id', string='Transfers') 43 delivery_count = fields.Integer(string='Delivery Orders', compute='_compute_picking_ids') 44 procurement_group_id = fields.Many2one('procurement.group', 'Procurement Group', copy=False) 45 effective_date = fields.Date("Effective Date", compute='_compute_effective_date', store=True, help="Completion date of the first delivery order.") 46 expected_date = fields.Datetime( help="Delivery date you can promise to the customer, computed from the minimum lead time of " 47 "the order lines in case of Service products. In case of shipping, the shipping policy of " 48 "the order will be taken into account to either use the minimum or maximum lead time of " 49 "the order lines.") 50 json_popover = fields.Char('JSON data for the popover widget', compute='_compute_json_popover') 51 show_json_popover = fields.Boolean('Has late picking', compute='_compute_json_popover') 52 53 def _init_column(self, column_name): 54 """ Ensure the default warehouse_id is correctly assigned 55 56 At column initialization, the ir.model.fields for res.users.property_warehouse_id isn't created, 57 which means trying to read the property field to get the default value will crash. 58 We therefore enforce the default here, without going through 59 the default function on the warehouse_id field. 60 """ 61 if column_name != "warehouse_id": 62 return super(SaleOrder, self)._init_column(column_name) 63 field = self._fields[column_name] 64 default = self.env['stock.warehouse'].search([('company_id', '=', self.env.company.id)], limit=1) 65 value = field.convert_to_write(default, self) 66 value = field.convert_to_column(value, self) 67 if value is not None: 68 _logger.debug("Table '%s': setting default value of new column %s to %r", 69 self._table, column_name, value) 70 query = 'UPDATE "%s" SET "%s"=%s WHERE "%s" IS NULL' % ( 71 self._table, column_name, field.column_format, column_name) 72 self._cr.execute(query, (value,)) 73 74 @api.depends('picking_ids.date_done') 75 def _compute_effective_date(self): 76 for order in self: 77 pickings = order.picking_ids.filtered(lambda x: x.state == 'done' and x.location_dest_id.usage == 'customer') 78 dates_list = [date for date in pickings.mapped('date_done') if date] 79 order.effective_date = fields.Date.context_today(order, min(dates_list)) if dates_list else False 80 81 @api.depends('picking_policy') 82 def _compute_expected_date(self): 83 super(SaleOrder, self)._compute_expected_date() 84 for order in self: 85 dates_list = [] 86 for line in order.order_line.filtered(lambda x: x.state != 'cancel' and not x._is_delivery() and not x.display_type): 87 dt = line._expected_date() 88 dates_list.append(dt) 89 if dates_list: 90 expected_date = min(dates_list) if order.picking_policy == 'direct' else max(dates_list) 91 order.expected_date = fields.Datetime.to_string(expected_date) 92 93 @api.model 94 def create(self, vals): 95 if 'warehouse_id' not in vals and 'company_id' in vals: 96 user = self.env['res.users'].browse(vals.get('user_id', False)) 97 vals['warehouse_id'] = user.with_company(vals.get('company_id'))._get_default_warehouse_id().id 98 return super().create(vals) 99 100 def write(self, values): 101 if values.get('order_line') and self.state == 'sale': 102 for order in self: 103 pre_order_line_qty = {order_line: order_line.product_uom_qty for order_line in order.mapped('order_line') if not order_line.is_expense} 104 105 if values.get('partner_shipping_id'): 106 new_partner = self.env['res.partner'].browse(values.get('partner_shipping_id')) 107 for record in self: 108 picking = record.mapped('picking_ids').filtered(lambda x: x.state not in ('done', 'cancel')) 109 addresses = (record.partner_shipping_id.display_name, new_partner.display_name) 110 message = _("""The delivery address has been changed on the Sales Order<br/> 111 From <strong>"%s"</strong> To <strong>"%s"</strong>, 112 You should probably update the partner on this document.""") % addresses 113 picking.activity_schedule('mail.mail_activity_data_warning', note=message, user_id=self.env.user.id) 114 115 if values.get('commitment_date'): 116 # protagate commitment_date as the deadline of the related stock move. 117 # TODO: Log a note on each down document 118 self.order_line.move_ids.date_deadline = fields.Datetime.to_datetime(values.get('commitment_date')) 119 120 res = super(SaleOrder, self).write(values) 121 if values.get('order_line') and self.state == 'sale': 122 for order in self: 123 to_log = {} 124 for order_line in order.order_line: 125 if float_compare(order_line.product_uom_qty, pre_order_line_qty.get(order_line, 0.0), order_line.product_uom.rounding) < 0: 126 to_log[order_line] = (order_line.product_uom_qty, pre_order_line_qty.get(order_line, 0.0)) 127 if to_log: 128 documents = self.env['stock.picking']._log_activity_get_documents(to_log, 'move_ids', 'UP') 129 documents = {k:v for k, v in documents.items() if k[0].state != 'cancel'} 130 order._log_decrease_ordered_quantity(documents) 131 return res 132 133 def _compute_json_popover(self): 134 for order in self: 135 late_stock_picking = order.picking_ids.filtered(lambda p: p.delay_alert_date) 136 order.json_popover = json.dumps({ 137 'popoverTemplate': 'sale_stock.DelayAlertWidget', 138 'late_elements': [{ 139 'id': late_move.id, 140 'name': late_move.display_name, 141 'model': 'stock.picking', 142 } for late_move in late_stock_picking 143 ] 144 }) 145 order.show_json_popover = bool(late_stock_picking) 146 147 def _action_confirm(self): 148 self.order_line._action_launch_stock_rule() 149 return super(SaleOrder, self)._action_confirm() 150 151 @api.depends('picking_ids') 152 def _compute_picking_ids(self): 153 for order in self: 154 order.delivery_count = len(order.picking_ids) 155 156 @api.onchange('company_id') 157 def _onchange_company_id(self): 158 if self.company_id: 159 warehouse_id = self.env['ir.default'].get_model_defaults('sale.order').get('warehouse_id') 160 self.warehouse_id = warehouse_id or self.user_id.with_company(self.company_id.id)._get_default_warehouse_id().id 161 162 @api.onchange('user_id') 163 def onchange_user_id(self): 164 super().onchange_user_id() 165 self.warehouse_id = self.user_id.with_company(self.company_id.id)._get_default_warehouse_id().id 166 167 @api.onchange('partner_shipping_id') 168 def _onchange_partner_shipping_id(self): 169 res = {} 170 pickings = self.picking_ids.filtered( 171 lambda p: p.state not in ['done', 'cancel'] and p.partner_id != self.partner_shipping_id 172 ) 173 if pickings: 174 res['warning'] = { 175 'title': _('Warning!'), 176 'message': _( 177 'Do not forget to change the partner on the following delivery orders: %s' 178 ) % (','.join(pickings.mapped('name'))) 179 } 180 return res 181 182 def action_view_delivery(self): 183 ''' 184 This function returns an action that display existing delivery orders 185 of given sales order ids. It can either be a in a list or in a form 186 view, if there is only one delivery order to show. 187 ''' 188 action = self.env["ir.actions.actions"]._for_xml_id("stock.action_picking_tree_all") 189 190 pickings = self.mapped('picking_ids') 191 if len(pickings) > 1: 192 action['domain'] = [('id', 'in', pickings.ids)] 193 elif pickings: 194 form_view = [(self.env.ref('stock.view_picking_form').id, 'form')] 195 if 'views' in action: 196 action['views'] = form_view + [(state,view) for state,view in action['views'] if view != 'form'] 197 else: 198 action['views'] = form_view 199 action['res_id'] = pickings.id 200 # Prepare the context. 201 picking_id = pickings.filtered(lambda l: l.picking_type_id.code == 'outgoing') 202 if picking_id: 203 picking_id = picking_id[0] 204 else: 205 picking_id = pickings[0] 206 action['context'] = dict(self._context, default_partner_id=self.partner_id.id, default_picking_type_id=picking_id.picking_type_id.id, default_origin=self.name, default_group_id=picking_id.group_id.id) 207 return action 208 209 def action_cancel(self): 210 documents = None 211 for sale_order in self: 212 if sale_order.state == 'sale' and sale_order.order_line: 213 sale_order_lines_quantities = {order_line: (order_line.product_uom_qty, 0) for order_line in sale_order.order_line} 214 documents = self.env['stock.picking']._log_activity_get_documents(sale_order_lines_quantities, 'move_ids', 'UP') 215 self.picking_ids.filtered(lambda p: p.state != 'done').action_cancel() 216 if documents: 217 filtered_documents = {} 218 for (parent, responsible), rendering_context in documents.items(): 219 if parent._name == 'stock.picking': 220 if parent.state == 'cancel': 221 continue 222 filtered_documents[(parent, responsible)] = rendering_context 223 self._log_decrease_ordered_quantity(filtered_documents, cancel=True) 224 return super(SaleOrder, self).action_cancel() 225 226 def _prepare_invoice(self): 227 invoice_vals = super(SaleOrder, self)._prepare_invoice() 228 invoice_vals['invoice_incoterm_id'] = self.incoterm.id 229 return invoice_vals 230 231 @api.model 232 def _get_customer_lead(self, product_tmpl_id): 233 super(SaleOrder, self)._get_customer_lead(product_tmpl_id) 234 return product_tmpl_id.sale_delay 235 236 def _log_decrease_ordered_quantity(self, documents, cancel=False): 237 238 def _render_note_exception_quantity_so(rendering_context): 239 order_exceptions, visited_moves = rendering_context 240 visited_moves = list(visited_moves) 241 visited_moves = self.env[visited_moves[0]._name].concat(*visited_moves) 242 order_line_ids = self.env['sale.order.line'].browse([order_line.id for order in order_exceptions.values() for order_line in order[0]]) 243 sale_order_ids = order_line_ids.mapped('order_id') 244 impacted_pickings = visited_moves.filtered(lambda m: m.state not in ('done', 'cancel')).mapped('picking_id') 245 values = { 246 'sale_order_ids': sale_order_ids, 247 'order_exceptions': order_exceptions.values(), 248 'impacted_pickings': impacted_pickings, 249 'cancel': cancel 250 } 251 return self.env.ref('sale_stock.exception_on_so')._render(values=values) 252 253 self.env['stock.picking']._log_activity(_render_note_exception_quantity_so, documents) 254 255 def _show_cancel_wizard(self): 256 res = super(SaleOrder, self)._show_cancel_wizard() 257 for order in self: 258 if any(picking.state == 'done' for picking in order.picking_ids) and not order._context.get('disable_cancel_warning'): 259 return True 260 return res 261 262class SaleOrderLine(models.Model): 263 _inherit = 'sale.order.line' 264 265 qty_delivered_method = fields.Selection(selection_add=[('stock_move', 'Stock Moves')]) 266 product_packaging = fields.Many2one( 'product.packaging', string='Package', default=False, check_company=True) 267 route_id = fields.Many2one('stock.location.route', string='Route', domain=[('sale_selectable', '=', True)], ondelete='restrict', check_company=True) 268 move_ids = fields.One2many('stock.move', 'sale_line_id', string='Stock Moves') 269 product_type = fields.Selection(related='product_id.type') 270 virtual_available_at_date = fields.Float(compute='_compute_qty_at_date', digits='Product Unit of Measure') 271 scheduled_date = fields.Datetime(compute='_compute_qty_at_date') 272 forecast_expected_date = fields.Datetime(compute='_compute_qty_at_date') 273 free_qty_today = fields.Float(compute='_compute_qty_at_date', digits='Product Unit of Measure') 274 qty_available_today = fields.Float(compute='_compute_qty_at_date') 275 warehouse_id = fields.Many2one(related='order_id.warehouse_id') 276 qty_to_deliver = fields.Float(compute='_compute_qty_to_deliver', digits='Product Unit of Measure') 277 is_mto = fields.Boolean(compute='_compute_is_mto') 278 display_qty_widget = fields.Boolean(compute='_compute_qty_to_deliver') 279 280 @api.depends('product_type', 'product_uom_qty', 'qty_delivered', 'state', 'move_ids', 'product_uom') 281 def _compute_qty_to_deliver(self): 282 """Compute the visibility of the inventory widget.""" 283 for line in self: 284 line.qty_to_deliver = line.product_uom_qty - line.qty_delivered 285 if line.state in ('draft', 'sent', 'sale') and line.product_type == 'product' and line.product_uom and line.qty_to_deliver > 0: 286 if line.state == 'sale' and not line.move_ids: 287 line.display_qty_widget = False 288 else: 289 line.display_qty_widget = True 290 else: 291 line.display_qty_widget = False 292 293 @api.depends( 294 'product_id', 'customer_lead', 'product_uom_qty', 'product_uom', 'order_id.commitment_date', 295 'move_ids', 'move_ids.forecast_expected_date', 'move_ids.forecast_availability') 296 def _compute_qty_at_date(self): 297 """ Compute the quantity forecasted of product at delivery date. There are 298 two cases: 299 1. The quotation has a commitment_date, we take it as delivery date 300 2. The quotation hasn't commitment_date, we compute the estimated delivery 301 date based on lead time""" 302 treated = self.browse() 303 # If the state is already in sale the picking is created and a simple forecasted quantity isn't enough 304 # Then used the forecasted data of the related stock.move 305 for line in self.filtered(lambda l: l.state == 'sale'): 306 if not line.display_qty_widget: 307 continue 308 moves = line.move_ids.filtered(lambda m: m.product_id == line.product_id) 309 line.forecast_expected_date = max(moves.filtered("forecast_expected_date").mapped("forecast_expected_date"), default=False) 310 line.qty_available_today = 0 311 line.free_qty_today = 0 312 for move in moves: 313 line.qty_available_today += move.product_uom._compute_quantity(move.reserved_availability, line.product_uom) 314 line.free_qty_today += move.product_id.uom_id._compute_quantity(move.forecast_availability, line.product_uom) 315 line.scheduled_date = line.order_id.commitment_date or line._expected_date() 316 line.virtual_available_at_date = False 317 treated |= line 318 319 qty_processed_per_product = defaultdict(lambda: 0) 320 grouped_lines = defaultdict(lambda: self.env['sale.order.line']) 321 # We first loop over the SO lines to group them by warehouse and schedule 322 # date in order to batch the read of the quantities computed field. 323 for line in self.filtered(lambda l: l.state in ('draft', 'sent')): 324 if not (line.product_id and line.display_qty_widget): 325 continue 326 grouped_lines[(line.warehouse_id.id, line.order_id.commitment_date or line._expected_date())] |= line 327 328 for (warehouse, scheduled_date), lines in grouped_lines.items(): 329 product_qties = lines.mapped('product_id').with_context(to_date=scheduled_date, warehouse=warehouse).read([ 330 'qty_available', 331 'free_qty', 332 'virtual_available', 333 ]) 334 qties_per_product = { 335 product['id']: (product['qty_available'], product['free_qty'], product['virtual_available']) 336 for product in product_qties 337 } 338 for line in lines: 339 line.scheduled_date = scheduled_date 340 qty_available_today, free_qty_today, virtual_available_at_date = qties_per_product[line.product_id.id] 341 line.qty_available_today = qty_available_today - qty_processed_per_product[line.product_id.id] 342 line.free_qty_today = free_qty_today - qty_processed_per_product[line.product_id.id] 343 line.virtual_available_at_date = virtual_available_at_date - qty_processed_per_product[line.product_id.id] 344 line.forecast_expected_date = False 345 product_qty = line.product_uom_qty 346 if line.product_uom and line.product_id.uom_id and line.product_uom != line.product_id.uom_id: 347 line.qty_available_today = line.product_id.uom_id._compute_quantity(line.qty_available_today, line.product_uom) 348 line.free_qty_today = line.product_id.uom_id._compute_quantity(line.free_qty_today, line.product_uom) 349 line.virtual_available_at_date = line.product_id.uom_id._compute_quantity(line.virtual_available_at_date, line.product_uom) 350 product_qty = line.product_uom._compute_quantity(product_qty, line.product_id.uom_id) 351 qty_processed_per_product[line.product_id.id] += product_qty 352 treated |= lines 353 remaining = (self - treated) 354 remaining.virtual_available_at_date = False 355 remaining.scheduled_date = False 356 remaining.forecast_expected_date = False 357 remaining.free_qty_today = False 358 remaining.qty_available_today = False 359 360 @api.depends('product_id', 'route_id', 'order_id.warehouse_id', 'product_id.route_ids') 361 def _compute_is_mto(self): 362 """ Verify the route of the product based on the warehouse 363 set 'is_available' at True if the product availibility in stock does 364 not need to be verified, which is the case in MTO, Cross-Dock or Drop-Shipping 365 """ 366 self.is_mto = False 367 for line in self: 368 if not line.display_qty_widget: 369 continue 370 product = line.product_id 371 product_routes = line.route_id or (product.route_ids + product.categ_id.total_route_ids) 372 373 # Check MTO 374 mto_route = line.order_id.warehouse_id.mto_pull_id.route_id 375 if not mto_route: 376 try: 377 mto_route = self.env['stock.warehouse']._find_global_route('stock.route_warehouse0_mto', _('Make To Order')) 378 except UserError: 379 # if route MTO not found in ir_model_data, we treat the product as in MTS 380 pass 381 382 if mto_route and mto_route in product_routes: 383 line.is_mto = True 384 else: 385 line.is_mto = False 386 387 @api.depends('product_id') 388 def _compute_qty_delivered_method(self): 389 """ Stock module compute delivered qty for product [('type', 'in', ['consu', 'product'])] 390 For SO line coming from expense, no picking should be generate: we don't manage stock for 391 thoses lines, even if the product is a storable. 392 """ 393 super(SaleOrderLine, self)._compute_qty_delivered_method() 394 395 for line in self: 396 if not line.is_expense and line.product_id.type in ['consu', 'product']: 397 line.qty_delivered_method = 'stock_move' 398 399 @api.depends('move_ids.state', 'move_ids.scrapped', 'move_ids.product_uom_qty', 'move_ids.product_uom') 400 def _compute_qty_delivered(self): 401 super(SaleOrderLine, self)._compute_qty_delivered() 402 403 for line in self: # TODO: maybe one day, this should be done in SQL for performance sake 404 if line.qty_delivered_method == 'stock_move': 405 qty = 0.0 406 outgoing_moves, incoming_moves = line._get_outgoing_incoming_moves() 407 for move in outgoing_moves: 408 if move.state != 'done': 409 continue 410 qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP') 411 for move in incoming_moves: 412 if move.state != 'done': 413 continue 414 qty -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP') 415 line.qty_delivered = qty 416 417 @api.model_create_multi 418 def create(self, vals_list): 419 lines = super(SaleOrderLine, self).create(vals_list) 420 lines.filtered(lambda line: line.state == 'sale')._action_launch_stock_rule() 421 return lines 422 423 def write(self, values): 424 lines = self.env['sale.order.line'] 425 if 'product_uom_qty' in values: 426 precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') 427 lines = self.filtered( 428 lambda r: r.state == 'sale' and not r.is_expense and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == -1) 429 previous_product_uom_qty = {line.id: line.product_uom_qty for line in lines} 430 res = super(SaleOrderLine, self).write(values) 431 if lines: 432 lines._action_launch_stock_rule(previous_product_uom_qty) 433 if 'customer_lead' in values and self.state == 'sale' and not self.order_id.commitment_date: 434 # Propagate deadline on related stock move 435 self.move_ids.date_deadline = self.order_id.date_order + timedelta(days=self.customer_lead or 0.0) 436 return res 437 438 @api.depends('order_id.state') 439 def _compute_invoice_status(self): 440 def check_moves_state(moves): 441 # All moves states are either 'done' or 'cancel', and there is at least one 'done' 442 at_least_one_done = False 443 for move in moves: 444 if move.state not in ['done', 'cancel']: 445 return False 446 at_least_one_done = at_least_one_done or move.state == 'done' 447 return at_least_one_done 448 super(SaleOrderLine, self)._compute_invoice_status() 449 for line in self: 450 # We handle the following specific situation: a physical product is partially delivered, 451 # but we would like to set its invoice status to 'Fully Invoiced'. The use case is for 452 # products sold by weight, where the delivered quantity rarely matches exactly the 453 # quantity ordered. 454 if line.order_id.state == 'done'\ 455 and line.invoice_status == 'no'\ 456 and line.product_id.type in ['consu', 'product']\ 457 and line.product_id.invoice_policy == 'delivery'\ 458 and line.move_ids \ 459 and check_moves_state(line.move_ids): 460 line.invoice_status = 'invoiced' 461 462 @api.depends('move_ids') 463 def _compute_product_updatable(self): 464 for line in self: 465 if not line.move_ids.filtered(lambda m: m.state != 'cancel'): 466 super(SaleOrderLine, line)._compute_product_updatable() 467 else: 468 line.product_updatable = False 469 470 @api.onchange('product_id') 471 def _onchange_product_id_set_customer_lead(self): 472 self.customer_lead = self.product_id.sale_delay 473 474 @api.onchange('product_packaging') 475 def _onchange_product_packaging(self): 476 if self.product_packaging: 477 return self._check_package() 478 479 @api.onchange('product_uom_qty') 480 def _onchange_product_uom_qty(self): 481 # When modifying a one2many, _origin doesn't guarantee that its values will be the ones 482 # in database. Hence, we need to explicitly read them from there. 483 if self._origin: 484 product_uom_qty_origin = self._origin.read(["product_uom_qty"])[0]["product_uom_qty"] 485 else: 486 product_uom_qty_origin = 0 487 488 if self.state == 'sale' and self.product_id.type in ['product', 'consu'] and self.product_uom_qty < product_uom_qty_origin: 489 # Do not display this warning if the new quantity is below the delivered 490 # one; the `write` will raise an `UserError` anyway. 491 if self.product_uom_qty < self.qty_delivered: 492 return {} 493 warning_mess = { 494 'title': _('Ordered quantity decreased!'), 495 'message' : _('You are decreasing the ordered quantity! Do not forget to manually update the delivery order if needed.'), 496 } 497 return {'warning': warning_mess} 498 return {} 499 500 def _prepare_procurement_values(self, group_id=False): 501 """ Prepare specific key for moves or other components that will be created from a stock rule 502 comming from a sale order line. This method could be override in order to add other custom key that could 503 be used in move/po creation. 504 """ 505 values = super(SaleOrderLine, self)._prepare_procurement_values(group_id) 506 self.ensure_one() 507 # Use the delivery date if there is else use date_order and lead time 508 date_deadline = self.order_id.commitment_date or (self.order_id.date_order + timedelta(days=self.customer_lead or 0.0)) 509 date_planned = date_deadline - timedelta(days=self.order_id.company_id.security_lead) 510 values.update({ 511 'group_id': group_id, 512 'sale_line_id': self.id, 513 'date_planned': date_planned, 514 'date_deadline': date_deadline, 515 'route_ids': self.route_id, 516 'warehouse_id': self.order_id.warehouse_id or False, 517 'partner_id': self.order_id.partner_shipping_id.id, 518 'product_description_variants': self._get_sale_order_line_multiline_description_variants(), 519 'company_id': self.order_id.company_id, 520 }) 521 return values 522 523 def _get_qty_procurement(self, previous_product_uom_qty=False): 524 self.ensure_one() 525 qty = 0.0 526 outgoing_moves, incoming_moves = self._get_outgoing_incoming_moves() 527 for move in outgoing_moves: 528 qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP') 529 for move in incoming_moves: 530 qty -= move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP') 531 return qty 532 533 def _get_outgoing_incoming_moves(self): 534 outgoing_moves = self.env['stock.move'] 535 incoming_moves = self.env['stock.move'] 536 537 for move in self.move_ids.filtered(lambda r: r.state != 'cancel' and not r.scrapped and self.product_id == r.product_id): 538 if move.location_dest_id.usage == "customer": 539 if not move.origin_returned_move_id or (move.origin_returned_move_id and move.to_refund): 540 outgoing_moves |= move 541 elif move.location_dest_id.usage != "customer" and move.to_refund: 542 incoming_moves |= move 543 544 return outgoing_moves, incoming_moves 545 546 def _get_procurement_group(self): 547 return self.order_id.procurement_group_id 548 549 def _prepare_procurement_group_vals(self): 550 return { 551 'name': self.order_id.name, 552 'move_type': self.order_id.picking_policy, 553 'sale_id': self.order_id.id, 554 'partner_id': self.order_id.partner_shipping_id.id, 555 } 556 557 def _action_launch_stock_rule(self, previous_product_uom_qty=False): 558 """ 559 Launch procurement group run method with required/custom fields genrated by a 560 sale order line. procurement group will launch '_run_pull', '_run_buy' or '_run_manufacture' 561 depending on the sale order line product rule. 562 """ 563 precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') 564 procurements = [] 565 for line in self: 566 line = line.with_company(line.company_id) 567 if line.state != 'sale' or not line.product_id.type in ('consu','product'): 568 continue 569 qty = line._get_qty_procurement(previous_product_uom_qty) 570 if float_compare(qty, line.product_uom_qty, precision_digits=precision) >= 0: 571 continue 572 573 group_id = line._get_procurement_group() 574 if not group_id: 575 group_id = self.env['procurement.group'].create(line._prepare_procurement_group_vals()) 576 line.order_id.procurement_group_id = group_id 577 else: 578 # In case the procurement group is already created and the order was 579 # cancelled, we need to update certain values of the group. 580 updated_vals = {} 581 if group_id.partner_id != line.order_id.partner_shipping_id: 582 updated_vals.update({'partner_id': line.order_id.partner_shipping_id.id}) 583 if group_id.move_type != line.order_id.picking_policy: 584 updated_vals.update({'move_type': line.order_id.picking_policy}) 585 if updated_vals: 586 group_id.write(updated_vals) 587 588 values = line._prepare_procurement_values(group_id=group_id) 589 product_qty = line.product_uom_qty - qty 590 591 line_uom = line.product_uom 592 quant_uom = line.product_id.uom_id 593 product_qty, procurement_uom = line_uom._adjust_uom_quantities(product_qty, quant_uom) 594 procurements.append(self.env['procurement.group'].Procurement( 595 line.product_id, product_qty, procurement_uom, 596 line.order_id.partner_shipping_id.property_stock_customer, 597 line.name, line.order_id.name, line.order_id.company_id, values)) 598 if procurements: 599 self.env['procurement.group'].run(procurements) 600 return True 601 602 def _check_package(self): 603 default_uom = self.product_id.uom_id 604 pack = self.product_packaging 605 qty = self.product_uom_qty 606 q = default_uom._compute_quantity(pack.qty, self.product_uom) 607 # We do not use the modulo operator to check if qty is a mltiple of q. Indeed the quantity 608 # per package might be a float, leading to incorrect results. For example: 609 # 8 % 1.6 = 1.5999999999999996 610 # 5.4 % 1.8 = 2.220446049250313e-16 611 if ( 612 qty 613 and q 614 and float_compare( 615 qty / q, float_round(qty / q, precision_rounding=1.0), precision_rounding=0.001 616 ) 617 != 0 618 ): 619 newqty = qty - (qty % q) + q 620 return { 621 'warning': { 622 'title': _('Warning'), 623 'message': _( 624 "This product is packaged by %(pack_size).2f %(pack_name)s. You should sell %(quantity).2f %(unit)s.", 625 pack_size=pack.qty, 626 pack_name=default_uom.name, 627 quantity=newqty, 628 unit=self.product_uom.name 629 ), 630 }, 631 } 632 return {} 633 634 def _update_line_quantity(self, values): 635 precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') 636 line_products = self.filtered(lambda l: l.product_id.type in ['product', 'consu']) 637 if line_products.mapped('qty_delivered') and float_compare(values['product_uom_qty'], max(line_products.mapped('qty_delivered')), precision_digits=precision) == -1: 638 raise UserError(_('You cannot decrease the ordered quantity below the delivered quantity.\n' 639 'Create a return first.')) 640 super(SaleOrderLine, self)._update_line_quantity(values) 641