# -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. from datetime import datetime, timedelta from flectra import api, fields, models, _ from flectra.tools import DEFAULT_SERVER_DATETIME_FORMAT, float_compare from flectra.exceptions import UserError class SaleOrder(models.Model): _inherit = "sale.order" @api.model def _default_warehouse_id(self): company = self.env.user.company_id.id warehouse_ids = self.env['stock.warehouse'].search([('company_id', '=', company)], limit=1) return warehouse_ids incoterm = fields.Many2one( 'stock.incoterms', 'Incoterms', help="International Commercial Terms are a series of predefined commercial terms used in international transactions.") picking_policy = fields.Selection([ ('direct', 'Deliver each product when available'), ('one', 'Deliver all products at once')], string='Shipping Policy', required=True, readonly=True, default='direct', states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}) warehouse_id = fields.Many2one( 'stock.warehouse', string='Warehouse', required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, default=_default_warehouse_id) picking_ids = fields.One2many('stock.picking', 'sale_id', string='Pickings') delivery_count = fields.Integer(string='Delivery Orders', compute='_compute_picking_ids') procurement_group_id = fields.Many2one('procurement.group', 'Procurement Group', copy=False) @api.multi def _action_confirm(self): super(SaleOrder, self)._action_confirm() for order in self: order.order_line._action_launch_procurement_rule() @api.depends('picking_ids') def _compute_picking_ids(self): for order in self: order.delivery_count = len(order.picking_ids) @api.onchange('warehouse_id') def _onchange_warehouse_id(self): if self.warehouse_id.company_id: self.company_id = self.warehouse_id.company_id.id @api.multi def action_view_delivery(self): ''' This function returns an action that display existing delivery orders of given sales order ids. It can either be a in a list or in a form view, if there is only one delivery order to show. ''' action = self.env.ref('stock.action_picking_tree_all').read()[0] pickings = self.mapped('picking_ids') if len(pickings) > 1: action['domain'] = [('id', 'in', pickings.ids)] elif pickings: action['views'] = [(self.env.ref('stock.view_picking_form').id, 'form')] action['res_id'] = pickings.id return action @api.multi def action_cancel(self): self.mapped('picking_ids').action_cancel() return super(SaleOrder, self).action_cancel() @api.multi def _prepare_invoice(self): invoice_vals = super(SaleOrder, self)._prepare_invoice() invoice_vals['incoterms_id'] = self.incoterm.id or False return invoice_vals @api.model def _get_customer_lead(self, product_tmpl_id): super(SaleOrder, self)._get_customer_lead(product_tmpl_id) return product_tmpl_id.sale_delay class SaleOrderLine(models.Model): _inherit = 'sale.order.line' product_packaging = fields.Many2one('product.packaging', string='Package', default=False) route_id = fields.Many2one('stock.location.route', string='Route', domain=[('sale_selectable', '=', True)], ondelete='restrict') move_ids = fields.One2many('stock.move', 'sale_line_id', string='Stock Moves') @api.model def create(self, values): line = super(SaleOrderLine, self).create(values) if line.state == 'sale': line._action_launch_procurement_rule() return line @api.multi def write(self, values): lines = False if 'product_uom_qty' in values: precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') lines = self.filtered( lambda r: r.state == 'sale' and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == -1) res = super(SaleOrderLine, self).write(values) if lines: lines._action_launch_procurement_rule() return res @api.depends('order_id.state') def _compute_invoice_status(self): super(SaleOrderLine, self)._compute_invoice_status() for line in self: # We handle the following specific situation: a physical product is partially delivered, # but we would like to set its invoice status to 'Fully Invoiced'. The use case is for # products sold by weight, where the delivered quantity rarely matches exactly the # quantity ordered. if line.order_id.state == 'done'\ and line.invoice_status == 'no'\ and line.product_id.type in ['consu', 'product']\ and line.product_id.invoice_policy == 'delivery'\ and line.move_ids \ and all(move.state in ['done', 'cancel'] for move in line.move_ids): line.invoice_status = 'invoiced' @api.depends('move_ids') def _compute_product_updatable(self): for line in self: if not line.move_ids.filtered(lambda m: m.state != 'cancel'): super(SaleOrderLine, line)._compute_product_updatable() else: line.product_updatable = False @api.multi @api.depends('product_id') def _compute_qty_delivered_updateable(self): for line in self: if line.product_id.type not in ('consu', 'product'): super(SaleOrderLine, line)._compute_qty_delivered_updateable() @api.onchange('product_id') def _onchange_product_id_set_customer_lead(self): self.customer_lead = self.product_id.sale_delay @api.onchange('product_packaging') def _onchange_product_packaging(self): if self.product_packaging: return self._check_package() @api.onchange('product_id') def _onchange_product_id_uom_check_availability(self): if not self.product_uom or (self.product_id.uom_id.category_id.id != self.product_uom.category_id.id): self.product_uom = self.product_id.uom_id self._onchange_product_id_check_availability() @api.onchange('product_uom_qty', 'product_uom', 'route_id') def _onchange_product_id_check_availability(self): if not self.product_id or not self.product_uom_qty or not self.product_uom: self.product_packaging = False return {} if self.product_id.type == 'product': precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') product = self.product_id.with_context(warehouse=self.order_id.warehouse_id.id) product_qty = self.product_uom._compute_quantity(self.product_uom_qty, self.product_id.uom_id) if float_compare(product.virtual_available, product_qty, precision_digits=precision) == -1: is_available = self._check_routing() if not is_available: message = _('You plan to sell %s %s but you only have %s %s available in %s warehouse.') % \ (self.product_uom_qty, self.product_uom.name, product.virtual_available, product.uom_id.name, self.order_id.warehouse_id.name) # We check if some products are available in other warehouses. if float_compare(product.virtual_available, self.product_id.virtual_available, precision_digits=precision) == -1: message += _('\nThere are %s %s available accross all warehouses.') % \ (self.product_id.virtual_available, product.uom_id.name) warning_mess = { 'title': _('Not enough inventory!'), 'message' : message } return {'warning': warning_mess} return {} @api.onchange('product_uom_qty') def _onchange_product_uom_qty(self): if self.state == 'sale' and self.product_id.type in ['product', 'consu'] and self.product_uom_qty < self._origin.product_uom_qty: # Do not display this warning if the new quantity is below the delivered # one; the `write` will raise an `UserError` anyway. if self.product_uom_qty < self.qty_delivered: return {} warning_mess = { 'title': _('Ordered quantity decreased!'), 'message' : _('You are decreasing the ordered quantity! Do not forget to manually update the delivery order if needed.'), } return {'warning': warning_mess} return {} @api.multi def _prepare_procurement_values(self, group_id=False): """ Prepare specific key for moves or other components that will be created from a procurement rule comming from a sale order line. This method could be override in order to add other custom key that could be used in move/po creation. """ values = super(SaleOrderLine, self)._prepare_procurement_values(group_id) self.ensure_one() date_planned = datetime.strptime(self.order_id.confirmation_date, DEFAULT_SERVER_DATETIME_FORMAT)\ + timedelta(days=self.customer_lead or 0.0) - timedelta(days=self.order_id.company_id.security_lead) values.update({ 'company_id': self.order_id.company_id, 'group_id': group_id, 'sale_line_id': self.id, 'date_planned': date_planned.strftime(DEFAULT_SERVER_DATETIME_FORMAT), 'route_ids': self.route_id, 'warehouse_id': self.order_id.warehouse_id or False, 'partner_dest_id': self.order_id.partner_shipping_id }) return values @api.multi def _action_launch_procurement_rule(self): """ Launch procurement group run method with required/custom fields genrated by a sale order line. procurement group will launch '_run_move', '_run_buy' or '_run_manufacture' depending on the sale order line product rule. """ precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') errors = [] for line in self: if line.state != 'sale' or not line.product_id.type in ('consu','product'): continue qty = 0.0 for move in line.move_ids.filtered(lambda r: r.state != 'cancel'): qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP') if float_compare(qty, line.product_uom_qty, precision_digits=precision) >= 0: continue group_id = line.order_id.procurement_group_id if not group_id: group_id = self.env['procurement.group'].create({ 'name': line.order_id.name, 'move_type': line.order_id.picking_policy, 'sale_id': line.order_id.id, 'partner_id': line.order_id.partner_shipping_id.id, }) line.order_id.procurement_group_id = group_id else: # In case the procurement group is already created and the order was # cancelled, we need to update certain values of the group. updated_vals = {} if group_id.partner_id != line.order_id.partner_shipping_id: updated_vals.update({'partner_id': line.order_id.partner_shipping_id.id}) if group_id.move_type != line.order_id.picking_policy: updated_vals.update({'move_type': line.order_id.picking_policy}) if updated_vals: group_id.write(updated_vals) values = line._prepare_procurement_values(group_id=group_id) product_qty = line.product_uom_qty - qty procurement_uom = line.product_uom quant_uom = line.product_id.uom_id get_param = self.env['ir.config_parameter'].sudo().get_param if procurement_uom.id != quant_uom.id and get_param('stock.propagate_uom') != '1': product_qty = line.product_uom._compute_quantity(product_qty, quant_uom, rounding_method='HALF-UP') procurement_uom = quant_uom try: self.env['procurement.group'].run(line.product_id, product_qty, procurement_uom, line.order_id.partner_shipping_id.property_stock_customer, line.name, line.order_id.name, values) except UserError as error: errors.append(error.name) if errors: raise UserError('\n'.join(errors)) return True @api.multi def _get_delivered_qty(self): self.ensure_one() super(SaleOrderLine, self)._get_delivered_qty() qty = 0.0 for move in self.move_ids.filtered(lambda r: r.state == 'done' and not r.scrapped): if move.location_dest_id.usage == "customer": if not move.origin_returned_move_id: qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom) elif move.location_dest_id.usage != "customer" and move.to_refund: qty -= move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom) return qty @api.multi def _check_package(self): default_uom = self.product_id.uom_id pack = self.product_packaging qty = self.product_uom_qty q = default_uom._compute_quantity(pack.qty, self.product_uom) if qty and q and (qty % q): newqty = qty - (qty % q) + q return { 'warning': { 'title': _('Warning'), 'message': _("This product is packaged by %.2f %s. You should sell %.2f %s.") % (pack.qty, default_uom.name, newqty, self.product_uom.name), }, } return {} def _check_routing(self): """ Verify the route of the product based on the warehouse return True if the product availibility in stock does not need to be verified, which is the case in MTO, Cross-Dock or Drop-Shipping """ is_available = False product_routes = self.route_id or (self.product_id.route_ids + self.product_id.categ_id.total_route_ids) # Check MTO wh_mto_route = self.order_id.warehouse_id.mto_pull_id.route_id if wh_mto_route and wh_mto_route <= product_routes: is_available = True else: mto_route = False try: mto_route = self.env['stock.warehouse']._get_mto_route() except UserError: # if route MTO not found in ir_model_data, we treat the product as in MTS pass if mto_route and mto_route in product_routes: is_available = True # Check Drop-Shipping if not is_available: for pull_rule in product_routes.mapped('pull_ids'): if pull_rule.picking_type_id.sudo().default_location_src_id.usage == 'supplier' and\ pull_rule.picking_type_id.sudo().default_location_dest_id.usage == 'customer': is_available = True break return is_available def _update_line_quantity(self, values): if self.mapped('qty_delivered') and values['product_uom_qty'] < max(self.mapped('qty_delivered')): raise UserError('You cannot decrease the ordered quantity below the delivered quantity.\n' 'Create a return first.') for line in self: pickings = line.order_id.picking_ids.filtered(lambda p: p.state not in ('done', 'cancel')) for picking in pickings: picking.message_post("The quantity of %s has been updated from %d to %d in %s" % (line.product_id.display_name, line.product_uom_qty, values['product_uom_qty'], line.order_id.name)) super(SaleOrderLine, self)._update_line_quantity(values)