347 lines
16 KiB
Python
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)
|