/
sale_order.py
412 lines (363 loc) · 20 KB
/
sale_order.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from odoo import api, fields, models, _
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, float_compare
from odoo.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 write(self, values):
if values.get('order_line') and self.state == 'sale':
for order in self:
pre_order_line_qty = {order_line: order_line.product_uom_qty for order_line in order.mapped('order_line')}
res = super(SaleOrder, self).write(values)
if values.get('order_line') and self.state == 'sale':
for order in self:
to_log = {}
for order_line in order.order_line:
if pre_order_line_qty.get(order_line, False) and float_compare(order_line.product_uom_qty, pre_order_line_qty[order_line], order_line.product_uom.rounding) < 0:
to_log[order_line] = (order_line.product_uom_qty, pre_order_line_qty[order_line])
if to_log:
documents = self.env['stock.picking']._log_activity_get_documents(to_log, 'move_ids', 'UP')
order._log_decrease_ordered_quantity(documents)
return res
@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):
documents = None
for sale_order in self:
if sale_order.state == 'sale' and sale_order.order_line:
sale_order_lines_quantities = {order_line: (order_line.product_uom_qty, 0) for order_line in sale_order.order_line}
documents = self.env['stock.picking']._log_activity_get_documents(sale_order_lines_quantities, 'move_ids', 'UP')
self.mapped('picking_ids').action_cancel()
if documents:
filtered_documents = {}
for (parent, responsible), rendering_context in documents.items():
if parent._name == 'stock.picking':
if parent.state == 'cancel':
continue
filtered_documents[(parent, responsible)] = rendering_context
self._log_decrease_ordered_quantity(filtered_documents, cancel=True)
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
def _log_decrease_ordered_quantity(self, documents, cancel=False):
def _render_note_exception_quantity_so(rendering_context):
order_exceptions, visited_moves = rendering_context
visited_moves = list(visited_moves)
visited_moves = self.env[visited_moves[0]._name].concat(*visited_moves)
order_line_ids = self.env['sale.order.line'].browse([order_line.id for order in order_exceptions.values() for order_line in order[0]])
sale_order_ids = order_line_ids.mapped('order_id')
impacted_pickings = visited_moves.filtered(lambda m: m.state not in ('done', 'cancel')).mapped('picking_id')
values = {
'sale_order_ids': sale_order_ids,
'order_exceptions': order_exceptions.values(),
'impacted_pickings': impacted_pickings,
'cancel': cancel
}
return self.env.ref('sale_stock.exception_on_so').render(values=values)
self.env['stock.picking']._log_activity(_render_note_exception_quantity_so, documents)
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
qty_delivered_method = fields.Selection(selection_add=[('stock_move', 'Stock Moves')])
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.multi
@api.depends('product_id')
def _compute_qty_delivered_method(self):
""" Stock module compute delivered qty for product [('type', 'in', ['consu', 'product'])]
For SO line coming from expense, no picking should be generate: we don't manage stock for
thoses lines, even if the product is a stockable.
"""
super(SaleOrderLine, self)._compute_qty_delivered_method()
for line in self:
if not line.is_expense and line.product_id.type in ['consu', 'product']:
line.qty_delivered_method = 'stock_move'
@api.multi
@api.depends('move_ids.state', 'move_ids.scrapped', 'move_ids.product_uom_qty', 'move_ids.product_uom')
def _compute_qty_delivered(self):
super(SaleOrderLine, self)._compute_qty_delivered()
for line in self: # TODO: maybe one day, this should be done in SQL for performance sake
if line.qty_delivered_method == 'stock_move':
qty = 0.0
for move in line.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 or (move.origin_returned_move_id and move.to_refund):
qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
elif move.location_dest_id.usage != "customer" and move.to_refund:
qty -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
line.qty_delivered = qty
@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 = self.env['sale.order.line']
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 not r.is_expense and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == -1)
previous_product_uom_qty = {line.id: line.product_uom_qty for line in lines}
res = super(SaleOrderLine, self).write(values)
if lines:
lines.with_context(previous_product_uom_qty=previous_product_uom_qty)._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.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,
lang=self.order_id.partner_id.lang or self.env.user.lang or 'en_US'
)
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 across all warehouses.\n\n') % \
(self.product_id.virtual_available, product.uom_id.name)
for warehouse in self.env['stock.warehouse'].search([]):
quantity = self.product_id.with_context(warehouse=warehouse.id).virtual_available
if quantity > 0:
message += "%s: %s %s\n" % (warehouse.name, quantity, self.product_id.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
def _get_qty_procurement(self):
self.ensure_one()
qty = 0.0
for move in self.move_ids.filtered(lambda r: r.state != 'cancel'):
qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
return qty
@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 = line._get_qty_procurement()
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 _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.'))
super(SaleOrderLine, self)._update_line_quantity(values)