flectra/addons/sale_stock/models/sale_order.py

347 lines
16 KiB
Python

# -*- 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)