[MERGE] Sync with trunk.
[odoo/odoo.git] / addons / sale / sale.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21
22 from datetime import datetime, timedelta
23 from dateutil.relativedelta import relativedelta
24 import time
25 from openerp import pooler
26 from openerp.osv import fields, osv
27 from openerp.tools.translate import _
28 from openerp.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT, DATETIME_FORMATS_MAP, float_compare
29 import openerp.addons.decimal_precision as dp
30 from openerp import netsvc
31
32 class sale_shop(osv.osv):
33     _name = "sale.shop"
34     _description = "Sales Shop"
35     _columns = {
36         'name': fields.char('Shop Name', size=64, required=True),
37         'payment_default_id': fields.many2one('account.payment.term', 'Default Payment Term', required=True),
38         'pricelist_id': fields.many2one('product.pricelist', 'Pricelist'),
39         'project_id': fields.many2one('account.analytic.account', 'Analytic Account', domain=[('parent_id', '!=', False)]),
40         'company_id': fields.many2one('res.company', 'Company', required=False),
41     }
42     _defaults = {
43         'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'sale.shop', context=c),
44     }
45
46 sale_shop()
47
48 class sale_order(osv.osv):
49     _name = "sale.order"
50     _inherit = ['mail.thread', 'ir.needaction_mixin']
51     _description = "Sales Order"
52     _track = {
53         'state': {
54             'sale.mt_order_confirmed': lambda self, cr, uid, obj, ctx=None: obj['state'] in ['manual', 'progress'],
55             'sale.mt_quotation_sent': lambda self, cr, uid, obj, ctx=None: obj['state'] in ['sent']
56         },
57     }
58
59     def onchange_shop_id(self, cr, uid, ids, shop_id, context=None):
60         v = {}
61         if shop_id:
62             shop = self.pool.get('sale.shop').browse(cr, uid, shop_id, context=context)
63             if shop.project_id.id:
64                 v['project_id'] = shop.project_id.id
65             if shop.pricelist_id.id:
66                 v['pricelist_id'] = shop.pricelist_id.id
67         return {'value': v}
68
69     def copy(self, cr, uid, id, default=None, context=None):
70         if not default:
71             default = {}
72         default.update({
73             'state': 'draft',
74             'invoice_ids': [],
75             'date_confirm': False,
76             'name': self.pool.get('ir.sequence').get(cr, uid, 'sale.order'),
77         })
78         return super(sale_order, self).copy(cr, uid, id, default, context=context)
79
80     def _amount_line_tax(self, cr, uid, line, context=None):
81         val = 0.0
82         for c in self.pool.get('account.tax').compute_all(cr, uid, line.tax_id, line.price_unit * (1-(line.discount or 0.0)/100.0), line.product_uom_qty, line.product_id, line.order_id.partner_id)['taxes']:
83             val += c.get('amount', 0.0)
84         return val
85
86     def _amount_all(self, cr, uid, ids, field_name, arg, context=None):
87         cur_obj = self.pool.get('res.currency')
88         res = {}
89         for order in self.browse(cr, uid, ids, context=context):
90             res[order.id] = {
91                 'amount_untaxed': 0.0,
92                 'amount_tax': 0.0,
93                 'amount_total': 0.0,
94             }
95             val = val1 = 0.0
96             cur = order.pricelist_id.currency_id
97             for line in order.order_line:
98                 val1 += line.price_subtotal
99                 val += self._amount_line_tax(cr, uid, line, context=context)
100             res[order.id]['amount_tax'] = cur_obj.round(cr, uid, cur, val)
101             res[order.id]['amount_untaxed'] = cur_obj.round(cr, uid, cur, val1)
102             res[order.id]['amount_total'] = res[order.id]['amount_untaxed'] + res[order.id]['amount_tax']
103         return res
104
105
106     def _invoiced_rate(self, cursor, user, ids, name, arg, context=None):
107         res = {}
108         for sale in self.browse(cursor, user, ids, context=context):
109             if sale.invoiced:
110                 res[sale.id] = 100.0
111                 continue
112             tot = 0.0
113             for invoice in sale.invoice_ids:
114                 if invoice.state not in ('draft', 'cancel'):
115                     tot += invoice.amount_untaxed
116             if tot:
117                 res[sale.id] = min(100.0, tot * 100.0 / (sale.amount_untaxed or 1.00))
118             else:
119                 res[sale.id] = 0.0
120         return res
121
122     def _invoice_exists(self, cursor, user, ids, name, arg, context=None):
123         res = {}
124         for sale in self.browse(cursor, user, ids, context=context):
125             res[sale.id] = False
126             if sale.invoice_ids:
127                 res[sale.id] = True
128         return res
129
130     def _invoiced(self, cursor, user, ids, name, arg, context=None):
131         res = {}
132         for sale in self.browse(cursor, user, ids, context=context):
133             res[sale.id] = True
134             invoice_existence = False
135             for invoice in sale.invoice_ids:
136                 if invoice.state!='cancel':
137                     invoice_existence = True
138                     if invoice.state != 'paid':
139                         res[sale.id] = False
140                         break
141             if not invoice_existence or sale.state == 'manual':
142                 res[sale.id] = False
143         return res
144
145     def _invoiced_search(self, cursor, user, obj, name, args, context=None):
146         if not len(args):
147             return []
148         clause = ''
149         sale_clause = ''
150         no_invoiced = False
151         for arg in args:
152             if arg[1] == '=':
153                 if arg[2]:
154                     clause += 'AND inv.state = \'paid\''
155                 else:
156                     clause += 'AND inv.state != \'cancel\' AND sale.state != \'cancel\'  AND inv.state <> \'paid\'  AND rel.order_id = sale.id '
157                     sale_clause = ',  sale_order AS sale '
158                     no_invoiced = True
159
160         cursor.execute('SELECT rel.order_id ' \
161                 'FROM sale_order_invoice_rel AS rel, account_invoice AS inv '+ sale_clause + \
162                 'WHERE rel.invoice_id = inv.id ' + clause)
163         res = cursor.fetchall()
164         if no_invoiced:
165             cursor.execute('SELECT sale.id ' \
166                     'FROM sale_order AS sale ' \
167                     'WHERE sale.id NOT IN ' \
168                         '(SELECT rel.order_id ' \
169                         'FROM sale_order_invoice_rel AS rel) and sale.state != \'cancel\'')
170             res.extend(cursor.fetchall())
171         if not res:
172             return [('id', '=', 0)]
173         return [('id', 'in', [x[0] for x in res])]
174
175     def _get_order(self, cr, uid, ids, context=None):
176         result = {}
177         for line in self.pool.get('sale.order.line').browse(cr, uid, ids, context=context):
178             result[line.order_id.id] = True
179         return result.keys()
180
181     _columns = {
182         'name': fields.char('Order Reference', size=64, required=True,
183             readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, select=True),
184         'shop_id': fields.many2one('sale.shop', 'Shop', required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}),
185         'origin': fields.char('Source Document', size=64, help="Reference of the document that generated this sales order request."),
186         'client_order_ref': fields.char('Customer Reference', size=64),
187         'state': fields.selection([
188             ('draft', 'Draft Quotation'),
189             ('sent', 'Quotation Sent'),
190             ('cancel', 'Cancelled'),
191             ('waiting_date', 'Waiting Schedule'),
192             ('progress', 'Sale Order'),
193             ('manual', 'Sale to Invoice'),
194             ('invoice_except', 'Invoice Exception'),
195             ('done', 'Done'),
196             ], 'Status', readonly=True, track_visibility=1,
197             help="Gives the status of the quotation or sales order. \nThe exception status is automatically set when a cancel operation occurs in the processing of a document linked to the sale order. \nThe 'Waiting Schedule' status is set when the invoice is confirmed but waiting for the scheduler to run on the order date.", select=True),
198         'date_order': fields.date('Date', required=True, readonly=True, select=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}),
199         'create_date': fields.datetime('Creation Date', readonly=True, select=True, help="Date on which sales order is created."),
200         'date_confirm': fields.date('Confirmation Date', readonly=True, select=True, help="Date on which sales order is confirmed."),
201         'user_id': fields.many2one('res.users', 'Salesperson', states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, select=True, track_visibility=1),
202         'partner_id': fields.many2one('res.partner', 'Customer', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, required=True, change_default=True, select=True, track_visibility=2),
203         'partner_invoice_id': fields.many2one('res.partner', 'Invoice Address', readonly=True, required=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="Invoice address for current sales order."),
204         'partner_shipping_id': fields.many2one('res.partner', 'Delivery Address', readonly=True, required=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="Delivery address for current sales order."),
205         'order_policy': fields.selection([
206                 ('manual', 'On Demand'),
207             ], 'Create Invoice', required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
208             help="""This field controls how invoice and delivery operations are synchronized.
209   - With 'Before Delivery', a draft invoice is created, and it must be paid before delivery."""),
210         'pricelist_id': fields.many2one('product.pricelist', 'Pricelist', required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="Pricelist for current sales order."),
211         'currency_id': fields.related('pricelist_id', 'currency_id', type="many2one", relation="res.currency", string="Currency", readonly=True, required=True),
212         'project_id': fields.many2one('account.analytic.account', 'Contract / Analytic', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="The analytic account related to a sales order."),
213
214         'order_line': fields.one2many('sale.order.line', 'order_id', 'Order Lines', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}),
215         'invoice_ids': fields.many2many('account.invoice', 'sale_order_invoice_rel', 'order_id', 'invoice_id', 'Invoices', readonly=True, help="This is the list of invoices that have been generated for this sales order. The same sales order may have been invoiced in several times (by line for example)."),
216         'invoiced_rate': fields.function(_invoiced_rate, string='Invoiced Ratio', type='float'),
217         'invoiced': fields.function(_invoiced, string='Paid',
218             fnct_search=_invoiced_search, type='boolean', help="It indicates that an invoice has been paid."),
219         'invoice_exists': fields.function(_invoice_exists, string='Invoiced',
220             fnct_search=_invoiced_search, type='boolean', help="It indicates that sale order has at least one invoice."),
221         'note': fields.text('Terms and conditions'),
222
223         'amount_untaxed': fields.function(_amount_all, digits_compute=dp.get_precision('Account'), string='Untaxed Amount',
224             store={
225                 'sale.order': (lambda self, cr, uid, ids, c={}: ids, ['order_line'], 10),
226                 'sale.order.line': (_get_order, ['price_unit', 'tax_id', 'discount', 'product_uom_qty'], 10),
227             },
228             multi='sums', help="The amount without tax.", track_visibility=2),
229         'amount_tax': fields.function(_amount_all, digits_compute=dp.get_precision('Account'), string='Taxes',
230             store={
231                 'sale.order': (lambda self, cr, uid, ids, c={}: ids, ['order_line'], 10),
232                 'sale.order.line': (_get_order, ['price_unit', 'tax_id', 'discount', 'product_uom_qty'], 10),
233             },
234             multi='sums', help="The tax amount."),
235         'amount_total': fields.function(_amount_all, digits_compute=dp.get_precision('Account'), string='Total',
236             store={
237                 'sale.order': (lambda self, cr, uid, ids, c={}: ids, ['order_line'], 10),
238                 'sale.order.line': (_get_order, ['price_unit', 'tax_id', 'discount', 'product_uom_qty'], 10),
239             },
240             multi='sums', help="The total amount."),
241
242         'invoice_quantity': fields.selection([('order', 'Ordered Quantities')], 'Invoice on', help="The sale order will automatically create the invoice proposition (draft invoice).", required=True, readonly=True, states={'draft': [('readonly', False)]}),
243         'payment_term': fields.many2one('account.payment.term', 'Payment Term'),
244         'fiscal_position': fields.many2one('account.fiscal.position', 'Fiscal Position'),
245         'company_id': fields.related('shop_id','company_id',type='many2one',relation='res.company',string='Company',store=True,readonly=True)
246     }
247     _defaults = {
248         'date_order': fields.date.context_today,
249         'order_policy': 'manual',
250         'state': 'draft',
251         'user_id': lambda obj, cr, uid, context: uid,
252         'name': lambda obj, cr, uid, context: '/',
253         'invoice_quantity': 'order',
254         'partner_invoice_id': lambda self, cr, uid, context: context.get('partner_id', False) and self.pool.get('res.partner').address_get(cr, uid, [context['partner_id']], ['invoice'])['invoice'],
255         'partner_shipping_id': lambda self, cr, uid, context: context.get('partner_id', False) and self.pool.get('res.partner').address_get(cr, uid, [context['partner_id']], ['delivery'])['delivery'],
256     }
257     _sql_constraints = [
258         ('name_uniq', 'unique(name, company_id)', 'Order Reference must be unique per Company!'),
259     ]
260     _order = 'name desc'
261
262     # Form filling
263     def unlink(self, cr, uid, ids, context=None):
264         sale_orders = self.read(cr, uid, ids, ['state'], context=context)
265         unlink_ids = []
266         for s in sale_orders:
267             if s['state'] in ['draft', 'cancel']:
268                 unlink_ids.append(s['id'])
269             else:
270                 raise osv.except_osv(_('Invalid Action!'), _('In order to delete a confirmed sale order, you must cancel it before !'))
271
272         return osv.osv.unlink(self, cr, uid, unlink_ids, context=context)
273
274     def copy_quotation(self, cr, uid, ids, context=None):
275         id = self.copy(cr, uid, ids[0], context=None)
276         view_ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'sale', 'view_order_form')
277         view_id = view_ref and view_ref[1] or False,
278         return {
279             'type': 'ir.actions.act_window',
280             'name': _('Sales Order'),
281             'res_model': 'sale.order',
282             'res_id': id,
283             'view_type': 'form',
284             'view_mode': 'form',
285             'view_id': view_id,
286             'target': 'current',
287             'nodestroy': True,
288         }
289
290     def onchange_pricelist_id(self, cr, uid, ids, pricelist_id, order_lines, context=None):
291         context = context or {}
292         if not pricelist_id:
293             return {}
294         value = {
295             'currency_id': self.pool.get('product.pricelist').browse(cr, uid, pricelist_id, context=context).currency_id.id
296         }
297         if not order_lines:
298             return {'value': value}
299         warning = {
300             'title': _('Pricelist Warning!'),
301             'message' : _('If you change the pricelist of this order (and eventually the currency), prices of existing order lines will not be updated.')
302         }
303         return {'warning': warning, 'value': value}
304
305     def onchange_partner_id(self, cr, uid, ids, part, context=None):
306         if not part:
307             return {'value': {'partner_invoice_id': False, 'partner_shipping_id': False,  'payment_term': False, 'fiscal_position': False}}
308
309         part = self.pool.get('res.partner').browse(cr, uid, part, context=context)
310         #if the chosen partner is not a company and has a parent company, use the parent to choose the delivery, the 
311         #invoicing addresses and all the fields related to the partner.
312         if part.parent_id and not part.is_company:
313             part = part.parent_id
314         addr = self.pool.get('res.partner').address_get(cr, uid, [part.id], ['delivery', 'invoice', 'contact'])
315         pricelist = part.property_product_pricelist and part.property_product_pricelist.id or False
316         payment_term = part.property_payment_term and part.property_payment_term.id or False
317         fiscal_position = part.property_account_position and part.property_account_position.id or False
318         dedicated_salesman = part.user_id and part.user_id.id or uid
319         val = {
320             'partner_invoice_id': addr['invoice'],
321             'partner_shipping_id': addr['delivery'],
322             'payment_term': payment_term,
323             'fiscal_position': fiscal_position,
324             'user_id': dedicated_salesman,
325         }
326         if pricelist:
327             val['pricelist_id'] = pricelist
328         return {'value': val}
329
330     def create(self, cr, uid, vals, context=None):
331         if vals.get('name','/')=='/':
332             vals['name'] = self.pool.get('ir.sequence').get(cr, uid, 'sale.order') or '/'
333         return super(sale_order, self).create(cr, uid, vals, context=context)
334
335     def button_dummy(self, cr, uid, ids, context=None):
336         return True
337
338     # FIXME: deprecated method, overriders should be using _prepare_invoice() instead.
339     #        can be removed after 6.1.
340     def _inv_get(self, cr, uid, order, context=None):
341         return {}
342
343     def _prepare_invoice(self, cr, uid, order, lines, context=None):
344         """Prepare the dict of values to create the new invoice for a
345            sale order. This method may be overridden to implement custom
346            invoice generation (making sure to call super() to establish
347            a clean extension chain).
348
349            :param browse_record order: sale.order record to invoice
350            :param list(int) line: list of invoice line IDs that must be
351                                   attached to the invoice
352            :return: dict of value to create() the invoice
353         """
354         if context is None:
355             context = {}
356         journal_ids = self.pool.get('account.journal').search(cr, uid,
357             [('type', '=', 'sale'), ('company_id', '=', order.company_id.id)],
358             limit=1)
359         if not journal_ids:
360             raise osv.except_osv(_('Error!'),
361                 _('Please define sales journal for this company: "%s" (id:%d).') % (order.company_id.name, order.company_id.id))
362         invoice_vals = {
363             'name': order.client_order_ref or '',
364             'origin': order.name,
365             'type': 'out_invoice',
366             'reference': order.client_order_ref or order.name,
367             'account_id': order.partner_id.property_account_receivable.id,
368             'partner_id': order.partner_invoice_id.id,
369             'journal_id': journal_ids[0],
370             'invoice_line': [(6, 0, lines)],
371             'currency_id': order.pricelist_id.currency_id.id,
372             'comment': order.note,
373             'payment_term': order.payment_term and order.payment_term.id or False,
374             'fiscal_position': order.fiscal_position.id or order.partner_id.property_account_position.id,
375             'date_invoice': context.get('date_invoice', False),
376             'company_id': order.company_id.id,
377             'user_id': order.user_id and order.user_id.id or False
378         }
379
380         # Care for deprecated _inv_get() hook - FIXME: to be removed after 6.1
381         invoice_vals.update(self._inv_get(cr, uid, order, context=context))
382         return invoice_vals
383
384     def _make_invoice(self, cr, uid, order, lines, context=None):
385         inv_obj = self.pool.get('account.invoice')
386         obj_invoice_line = self.pool.get('account.invoice.line')
387         if context is None:
388             context = {}
389         invoiced_sale_line_ids = self.pool.get('sale.order.line').search(cr, uid, [('order_id', '=', order.id), ('invoiced', '=', True)], context=context)
390         from_line_invoice_ids = []
391         for invoiced_sale_line_id in self.pool.get('sale.order.line').browse(cr, uid, invoiced_sale_line_ids, context=context):
392             for invoice_line_id in invoiced_sale_line_id.invoice_lines:
393                 if invoice_line_id.invoice_id.id not in from_line_invoice_ids:
394                     from_line_invoice_ids.append(invoice_line_id.invoice_id.id)
395         for preinv in order.invoice_ids:
396             if preinv.state not in ('cancel',) and preinv.id not in from_line_invoice_ids:
397                 for preline in preinv.invoice_line:
398                     inv_line_id = obj_invoice_line.copy(cr, uid, preline.id, {'invoice_id': False, 'price_unit': -preline.price_unit})
399                     lines.append(inv_line_id)
400         inv = self._prepare_invoice(cr, uid, order, lines, context=context)
401         inv_id = inv_obj.create(cr, uid, inv, context=context)
402         data = inv_obj.onchange_payment_term_date_invoice(cr, uid, [inv_id], inv['payment_term'], time.strftime(DEFAULT_SERVER_DATE_FORMAT))
403         if data.get('value', False):
404             inv_obj.write(cr, uid, [inv_id], data['value'], context=context)
405         inv_obj.button_compute(cr, uid, [inv_id])
406         return inv_id
407
408     def print_quotation(self, cr, uid, ids, context=None):
409         '''
410         This function prints the sale order and mark it as sent, so that we can see more easily the next step of the workflow
411         '''
412         assert len(ids) == 1, 'This option should only be used for a single id at a time'
413         wf_service = netsvc.LocalService("workflow")
414         wf_service.trg_validate(uid, 'sale.order', ids[0], 'quotation_sent', cr)
415         datas = {
416                  'model': 'sale.order',
417                  'ids': ids,
418                  'form': self.read(cr, uid, ids[0], context=context),
419         }
420         return {'type': 'ir.actions.report.xml', 'report_name': 'sale.order', 'datas': datas, 'nodestroy': True}
421
422     def manual_invoice(self, cr, uid, ids, context=None):
423         """ create invoices for the given sale orders (ids), and open the form
424             view of one of the newly created invoices
425         """
426         mod_obj = self.pool.get('ir.model.data')
427         wf_service = netsvc.LocalService("workflow")
428
429         # create invoices through the sale orders' workflow
430         inv_ids0 = set(inv.id for sale in self.browse(cr, uid, ids, context) for inv in sale.invoice_ids)
431         for id in ids:
432             wf_service.trg_validate(uid, 'sale.order', id, 'manual_invoice', cr)
433         inv_ids1 = set(inv.id for sale in self.browse(cr, uid, ids, context) for inv in sale.invoice_ids)
434         # determine newly created invoices
435         new_inv_ids = list(inv_ids1 - inv_ids0)
436
437         res = mod_obj.get_object_reference(cr, uid, 'account', 'invoice_form')
438         res_id = res and res[1] or False,
439
440         return {
441             'name': _('Customer Invoices'),
442             'view_type': 'form',
443             'view_mode': 'form',
444             'view_id': [res_id],
445             'res_model': 'account.invoice',
446             'context': "{'type':'out_invoice'}",
447             'type': 'ir.actions.act_window',
448             'nodestroy': True,
449             'target': 'current',
450             'res_id': new_inv_ids and new_inv_ids[0] or False,
451         }
452
453     def action_view_invoice(self, cr, uid, ids, context=None):
454         '''
455         This function returns an action that display existing invoices of given sale order ids. It can either be a in a list or in a form view, if there is only one invoice to show.
456         '''
457         mod_obj = self.pool.get('ir.model.data')
458         act_obj = self.pool.get('ir.actions.act_window')
459
460         result = mod_obj.get_object_reference(cr, uid, 'account', 'action_invoice_tree1')
461         id = result and result[1] or False
462         result = act_obj.read(cr, uid, [id], context=context)[0]
463         #compute the number of invoices to display
464         inv_ids = []
465         for so in self.browse(cr, uid, ids, context=context):
466             inv_ids += [invoice.id for invoice in so.invoice_ids]
467         #choose the view_mode accordingly
468         if len(inv_ids)>1:
469             result['domain'] = "[('id','in',["+','.join(map(str, inv_ids))+"])]"
470         else:
471             res = mod_obj.get_object_reference(cr, uid, 'account', 'invoice_form')
472             result['views'] = [(res and res[1] or False, 'form')]
473             result['res_id'] = inv_ids and inv_ids[0] or False
474         return result
475
476     def test_no_product(self, cr, uid, order, context):
477         for line in order.order_line:
478             if line.product_id and (line.product_id.type<>'service'):
479                 return False
480         return True
481
482     def action_invoice_create(self, cr, uid, ids, grouped=False, states=None, date_invoice = False, context=None):
483         if states is None:
484             states = ['confirmed', 'done', 'exception']
485         res = False
486         invoices = {}
487         invoice_ids = []
488         invoice = self.pool.get('account.invoice')
489         obj_sale_order_line = self.pool.get('sale.order.line')
490         partner_currency = {}
491         if context is None:
492             context = {}
493         # If date was specified, use it as date invoiced, usefull when invoices are generated this month and put the
494         # last day of the last month as invoice date
495         if date_invoice:
496             context['date_invoice'] = date_invoice
497         for o in self.browse(cr, uid, ids, context=context):
498             currency_id = o.pricelist_id.currency_id.id
499             if (o.partner_id.id in partner_currency) and (partner_currency[o.partner_id.id] <> currency_id):
500                 raise osv.except_osv(
501                     _('Error!'),
502                     _('You cannot group sales having different currencies for the same partner.'))
503
504             partner_currency[o.partner_id.id] = currency_id
505             lines = []
506             for line in o.order_line:
507                 if line.invoiced:
508                     continue
509                 elif (line.state in states):
510                     lines.append(line.id)
511             created_lines = obj_sale_order_line.invoice_line_create(cr, uid, lines)
512             if created_lines:
513                 invoices.setdefault(o.partner_id.id, []).append((o, created_lines))
514         if not invoices:
515             for o in self.browse(cr, uid, ids, context=context):
516                 for i in o.invoice_ids:
517                     if i.state == 'draft':
518                         return i.id
519         for val in invoices.values():
520             if grouped:
521                 res = self._make_invoice(cr, uid, val[0][0], reduce(lambda x, y: x + y, [l for o, l in val], []), context=context)
522                 invoice_ref = ''
523                 for o, l in val:
524                     invoice_ref += o.name + '|'
525                     self.write(cr, uid, [o.id], {'state': 'progress'})
526                     cr.execute('insert into sale_order_invoice_rel (order_id,invoice_id) values (%s,%s)', (o.id, res))
527                 invoice.write(cr, uid, [res], {'origin': invoice_ref, 'name': invoice_ref})
528             else:
529                 for order, il in val:
530                     res = self._make_invoice(cr, uid, order, il, context=context)
531                     invoice_ids.append(res)
532                     self.write(cr, uid, [order.id], {'state': 'progress'})
533                     cr.execute('insert into sale_order_invoice_rel (order_id,invoice_id) values (%s,%s)', (order.id, res))
534         return res
535
536     def action_invoice_cancel(self, cr, uid, ids, context=None):
537         self.write(cr, uid, ids, {'state': 'invoice_except'}, context=context)
538         return True
539
540     def action_invoice_end(self, cr, uid, ids, context=None):
541         for this in self.browse(cr, uid, ids, context=context):
542             for line in this.order_line:
543                 if line.state == 'exception':
544                     line.write({'state': 'confirmed'})
545             if this.state == 'invoice_except':
546                 this.write({'state': 'progress'})
547         return True
548
549     def action_cancel(self, cr, uid, ids, context=None):
550         wf_service = netsvc.LocalService("workflow")
551         if context is None:
552             context = {}
553         sale_order_line_obj = self.pool.get('sale.order.line')
554         for sale in self.browse(cr, uid, ids, context=context):
555             for inv in sale.invoice_ids:
556                 if inv.state not in ('draft', 'cancel'):
557                     raise osv.except_osv(
558                         _('Cannot cancel this sales order!'),
559                         _('First cancel all invoices attached to this sales order.'))
560             for r in self.read(cr, uid, ids, ['invoice_ids']):
561                 for inv in r['invoice_ids']:
562                     wf_service.trg_validate(uid, 'account.invoice', inv, 'invoice_cancel', cr)
563             sale_order_line_obj.write(cr, uid, [l.id for l in  sale.order_line],
564                     {'state': 'cancel'})
565         self.write(cr, uid, ids, {'state': 'cancel'})
566         return True
567
568     def action_button_confirm(self, cr, uid, ids, context=None):
569         assert len(ids) == 1, 'This option should only be used for a single id at a time.'
570         wf_service = netsvc.LocalService('workflow')
571         wf_service.trg_validate(uid, 'sale.order', ids[0], 'order_confirm', cr)
572
573         # redisplay the record as a sale order
574         view_ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'sale', 'view_order_form')
575         view_id = view_ref and view_ref[1] or False,
576         return {
577             'type': 'ir.actions.act_window',
578             'name': _('Sales Order'),
579             'res_model': 'sale.order',
580             'res_id': ids[0],
581             'view_type': 'form',
582             'view_mode': 'form',
583             'view_id': view_id,
584             'target': 'current',
585             'nodestroy': True,
586         }
587
588     def action_wait(self, cr, uid, ids, context=None):
589         context = context or {}
590         for o in self.browse(cr, uid, ids):
591             if not o.order_line:
592                 raise osv.except_osv(_('Error!'),_('You cannot confirm a sale order which has no line.'))
593             noprod = self.test_no_product(cr, uid, o, context)
594             if (o.order_policy == 'manual') or noprod:
595                 self.write(cr, uid, [o.id], {'state': 'manual', 'date_confirm': fields.date.context_today(self, cr, uid, context=context)})
596             else:
597                 self.write(cr, uid, [o.id], {'state': 'progress', 'date_confirm': fields.date.context_today(self, cr, uid, context=context)})
598             self.pool.get('sale.order.line').button_confirm(cr, uid, [x.id for x in o.order_line])
599         return True
600
601     def action_quotation_send(self, cr, uid, ids, context=None):
602         '''
603         This function opens a window to compose an email, with the edi sale template message loaded by default
604         '''
605         assert len(ids) == 1, 'This option should only be used for a single id at a time.'
606         ir_model_data = self.pool.get('ir.model.data')
607         try:
608             template_id = ir_model_data.get_object_reference(cr, uid, 'sale', 'email_template_edi_sale')[1]
609         except ValueError:
610             template_id = False
611         try:
612             compose_form_id = ir_model_data.get_object_reference(cr, uid, 'mail', 'email_compose_message_wizard_form')[1]
613         except ValueError:
614             compose_form_id = False 
615         ctx = dict(context)
616         ctx.update({
617             'default_model': 'sale.order',
618             'default_res_id': ids[0],
619             'default_use_template': bool(template_id),
620             'default_template_id': template_id,
621             'default_composition_mode': 'comment',
622             'mark_so_as_sent': True
623         })
624         return {
625             'type': 'ir.actions.act_window',
626             'view_type': 'form',
627             'view_mode': 'form',
628             'res_model': 'mail.compose.message',
629             'views': [(compose_form_id, 'form')],
630             'view_id': compose_form_id,
631             'target': 'new',
632             'context': ctx,
633         }
634
635     def action_done(self, cr, uid, ids, context=None):
636         return self.write(cr, uid, ids, {'state': 'done'}, context=context)
637
638     # ------------------------------------------------
639     # OpenChatter methods and notifications
640     # ------------------------------------------------
641
642     def needaction_domain_get(self, cr, uid, ids, context=None):
643         return [('state', '=', 'draft'), ('user_id', '=', uid)]
644
645
646 # TODO add a field price_unit_uos
647 # - update it on change product and unit price
648 # - use it in report if there is a uos
649 class sale_order_line(osv.osv):
650
651     def _amount_line(self, cr, uid, ids, field_name, arg, context=None):
652         tax_obj = self.pool.get('account.tax')
653         cur_obj = self.pool.get('res.currency')
654         res = {}
655         if context is None:
656             context = {}
657         for line in self.browse(cr, uid, ids, context=context):
658             price = line.price_unit * (1 - (line.discount or 0.0) / 100.0)
659             taxes = tax_obj.compute_all(cr, uid, line.tax_id, price, line.product_uom_qty, line.product_id, line.order_id.partner_id)
660             cur = line.order_id.pricelist_id.currency_id
661             res[line.id] = cur_obj.round(cr, uid, cur, taxes['total'])
662         return res
663
664     def _get_uom_id(self, cr, uid, *args):
665         try:
666             proxy = self.pool.get('ir.model.data')
667             result = proxy.get_object_reference(cr, uid, 'product', 'product_uom_unit')
668             return result[1]
669         except Exception, ex:
670             return False
671
672     def _fnct_line_invoiced(self, cr, uid, ids, field_name, args, context=None):
673         res = dict.fromkeys(ids, False)
674         for this in self.browse(cr, uid, ids, context=context):
675             res[this.id] = this.invoice_lines and \
676                 all(iline.invoice_id.state != 'cancel' for iline in this.invoice_lines) 
677         return res
678
679     def _order_lines_from_invoice(self, cr, uid, ids, context=None):
680         # direct access to the m2m table is the less convoluted way to achieve this (and is ok ACL-wise)
681         cr.execute("""SELECT DISTINCT sol.id FROM sale_order_invoice_rel rel JOIN
682                                                   sale_order_line sol ON (sol.order_id = rel.order_id)
683                                     WHERE rel.invoice_id = ANY(%s)""", (list(ids),))
684         return [i[0] for i in cr.fetchall()]
685
686     _name = 'sale.order.line'
687     _description = 'Sales Order Line'
688     _columns = {
689         'order_id': fields.many2one('sale.order', 'Order Reference', required=True, ondelete='cascade', select=True, readonly=True, states={'draft':[('readonly',False)]}),
690         'name': fields.text('Description', required=True, select=True, readonly=True, states={'draft': [('readonly', False)]}),
691         'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of sales order lines."),
692         'product_id': fields.many2one('product.product', 'Product', domain=[('sale_ok', '=', True)], change_default=True),
693         'invoice_lines': fields.many2many('account.invoice.line', 'sale_order_line_invoice_rel', 'order_line_id', 'invoice_id', 'Invoice Lines', readonly=True),
694         'invoiced': fields.function(_fnct_line_invoiced, string='Invoiced', type='boolean',
695             store={'account.invoice': (_order_lines_from_invoice, ['state'], 10)}),
696         'price_unit': fields.float('Unit Price', required=True, digits_compute= dp.get_precision('Product Price'), readonly=True, states={'draft': [('readonly', False)]}),
697         'type': fields.selection([('make_to_stock', 'from stock'), ('make_to_order', 'on order')], 'Procurement Method', required=True, readonly=True, states={'draft': [('readonly', False)]},
698          help="From stock: When needed, the product is taken from the stock or we wait for replenishment.\nOn order: When needed, the product is purchased or produced."),
699         'price_subtotal': fields.function(_amount_line, string='Subtotal', digits_compute= dp.get_precision('Account')),
700         'tax_id': fields.many2many('account.tax', 'sale_order_tax', 'order_line_id', 'tax_id', 'Taxes', readonly=True, states={'draft': [('readonly', False)]}),
701         'address_allotment_id': fields.many2one('res.partner', 'Allotment Partner',help="A partner to whom the particular product needs to be allotted."),
702         'product_uom_qty': fields.float('Quantity', digits_compute= dp.get_precision('Product UoS'), required=True, readonly=True, states={'draft': [('readonly', False)]}),
703         'product_uom': fields.many2one('product.uom', 'Unit of Measure ', required=True, readonly=True, states={'draft': [('readonly', False)]}),
704         'product_uos_qty': fields.float('Quantity (UoS)' ,digits_compute= dp.get_precision('Product UoS'), readonly=True, states={'draft': [('readonly', False)]}),
705         'product_uos': fields.many2one('product.uom', 'Product UoS'),
706         'discount': fields.float('Discount (%)', digits_compute= dp.get_precision('Discount'), readonly=True, states={'draft': [('readonly', False)]}),
707         'th_weight': fields.float('Weight', readonly=True, states={'draft': [('readonly', False)]}),
708         'state': fields.selection([('cancel', 'Cancelled'),('draft', 'Draft'),('confirmed', 'Confirmed'),('exception', 'Exception'),('done', 'Done')], 'Status', required=True, readonly=True,
709                 help='* The \'Draft\' status is set when the related sales order in draft status. \
710                     \n* The \'Confirmed\' status is set when the related sales order is confirmed. \
711                     \n* The \'Exception\' status is set when the related sales order is set as exception. \
712                     \n* The \'Done\' status is set when the sales order line has been picked. \
713                     \n* The \'Cancelled\' status is set when a user cancel the sales order related.'),
714         'order_partner_id': fields.related('order_id', 'partner_id', type='many2one', relation='res.partner', store=True, string='Customer'),
715         'salesman_id':fields.related('order_id', 'user_id', type='many2one', relation='res.users', store=True, string='Salesperson'),
716         'company_id': fields.related('order_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True),
717     }
718     _order = 'order_id desc, sequence'
719     _defaults = {
720         'product_uom' : _get_uom_id,
721         'discount': 0.0,
722         'product_uom_qty': 1,
723         'product_uos_qty': 1,
724         'sequence': 10,
725         'state': 'draft',
726         'type': 'make_to_stock',
727         'price_unit': 0.0,
728     }
729
730     def _get_line_qty(self, cr, uid, line, context=None):
731         if (line.order_id.invoice_quantity=='order'):
732             if line.product_uos:
733                 return line.product_uos_qty or 0.0
734         return line.product_uom_qty
735
736     def _get_line_uom(self, cr, uid, line, context=None):
737         if (line.order_id.invoice_quantity=='order'):
738             if line.product_uos:
739                 return line.product_uos.id
740         return line.product_uom.id
741
742     def _prepare_order_line_invoice_line(self, cr, uid, line, account_id=False, context=None):
743         """Prepare the dict of values to create the new invoice line for a
744            sale order line. This method may be overridden to implement custom
745            invoice generation (making sure to call super() to establish
746            a clean extension chain).
747
748            :param browse_record line: sale.order.line record to invoice
749            :param int account_id: optional ID of a G/L account to force
750                (this is used for returning products including service)
751            :return: dict of values to create() the invoice line
752         """
753         res = {}
754         if not line.invoiced:
755             if not account_id:
756                 if line.product_id:
757                     account_id = line.product_id.property_account_income.id
758                     if not account_id:
759                         account_id = line.product_id.categ_id.property_account_income_categ.id
760                     if not account_id:
761                         raise osv.except_osv(_('Error!'),
762                                 _('Please define income account for this product: "%s" (id:%d).') % \
763                                     (line.product_id.name, line.product_id.id,))
764                 else:
765                     prop = self.pool.get('ir.property').get(cr, uid,
766                             'property_account_income_categ', 'product.category',
767                             context=context)
768                     account_id = prop and prop.id or False
769             uosqty = self._get_line_qty(cr, uid, line, context=context)
770             uos_id = self._get_line_uom(cr, uid, line, context=context)
771             pu = 0.0
772             if uosqty:
773                 pu = round(line.price_unit * line.product_uom_qty / uosqty,
774                         self.pool.get('decimal.precision').precision_get(cr, uid, 'Product Price'))
775             fpos = line.order_id.fiscal_position or False
776             account_id = self.pool.get('account.fiscal.position').map_account(cr, uid, fpos, account_id)
777             if not account_id:
778                 raise osv.except_osv(_('Error!'),
779                             _('There is no Fiscal Position defined or Income category account defined for default properties of Product categories.'))
780             res = {
781                 'name': line.name,
782                 'sequence': line.sequence,
783                 'origin': line.order_id.name,
784                 'account_id': account_id,
785                 'price_unit': pu,
786                 'quantity': uosqty,
787                 'discount': line.discount,
788                 'uos_id': uos_id,
789                 'product_id': line.product_id.id or False,
790                 'invoice_line_tax_id': [(6, 0, [x.id for x in line.tax_id])],
791                 'account_analytic_id': line.order_id.project_id and line.order_id.project_id.id or False,
792             }
793
794         return res
795
796     def invoice_line_create(self, cr, uid, ids, context=None):
797         if context is None:
798             context = {}
799
800         create_ids = []
801         sales = set()
802         for line in self.browse(cr, uid, ids, context=context):
803             vals = self._prepare_order_line_invoice_line(cr, uid, line, False, context)
804             if vals:
805                 inv_id = self.pool.get('account.invoice.line').create(cr, uid, vals, context=context)
806                 cr.execute('insert into sale_order_line_invoice_rel (order_line_id,invoice_id) values (%s,%s)', (line.id, inv_id))
807                 sales.add(line.order_id.id)
808                 create_ids.append(inv_id)
809         # Trigger workflow events
810         wf_service = netsvc.LocalService("workflow")
811         for sale_id in sales:
812             wf_service.trg_write(uid, 'sale.order', sale_id, cr)
813         return create_ids
814
815     def button_cancel(self, cr, uid, ids, context=None):
816         for line in self.browse(cr, uid, ids, context=context):
817             if line.invoiced:
818                 raise osv.except_osv(_('Invalid Action!'), _('You cannot cancel a sale order line that has already been invoiced.'))
819         return self.write(cr, uid, ids, {'state': 'cancel'})
820
821     def button_confirm(self, cr, uid, ids, context=None):
822         return self.write(cr, uid, ids, {'state': 'confirmed'})
823
824     def button_done(self, cr, uid, ids, context=None):
825         wf_service = netsvc.LocalService("workflow")
826         res = self.write(cr, uid, ids, {'state': 'done'})
827         for line in self.browse(cr, uid, ids, context=context):
828             wf_service.trg_write(uid, 'sale.order', line.order_id.id, cr)
829         return res
830
831     def uos_change(self, cr, uid, ids, product_uos, product_uos_qty=0, product_id=None):
832         product_obj = self.pool.get('product.product')
833         if not product_id:
834             return {'value': {'product_uom': product_uos,
835                 'product_uom_qty': product_uos_qty}, 'domain': {}}
836
837         product = product_obj.browse(cr, uid, product_id)
838         value = {
839             'product_uom': product.uom_id.id,
840         }
841         # FIXME must depend on uos/uom of the product and not only of the coeff.
842         try:
843             value.update({
844                 'product_uom_qty': product_uos_qty / product.uos_coeff,
845                 'th_weight': product_uos_qty / product.uos_coeff * product.weight
846             })
847         except ZeroDivisionError:
848             pass
849         return {'value': value}
850
851     def copy_data(self, cr, uid, id, default=None, context=None):
852         if not default:
853             default = {}
854         default.update({'state': 'draft',  'invoice_lines': []})
855         return super(sale_order_line, self).copy_data(cr, uid, id, default, context=context)
856
857     def product_id_change(self, cr, uid, ids, pricelist, product, qty=0,
858             uom=False, qty_uos=0, uos=False, name='', partner_id=False,
859             lang=False, update_tax=True, date_order=False, packaging=False, fiscal_position=False, flag=False, context=None):
860         context = context or {}
861         lang = lang or context.get('lang',False)
862         if not  partner_id:
863             raise osv.except_osv(_('No Customer Defined !'), _('Before choosing a product,\n select a customer in the sales form.'))
864         warning = {}
865         product_uom_obj = self.pool.get('product.uom')
866         partner_obj = self.pool.get('res.partner')
867         product_obj = self.pool.get('product.product')
868         context = {'lang': lang, 'partner_id': partner_id}
869         if partner_id:
870             lang = partner_obj.browse(cr, uid, partner_id).lang
871         context_partner = {'lang': lang, 'partner_id': partner_id}
872
873         if not product:
874             return {'value': {'th_weight': 0,
875                 'product_uos_qty': qty}, 'domain': {'product_uom': [],
876                    'product_uos': []}}
877         if not date_order:
878             date_order = time.strftime(DEFAULT_SERVER_DATE_FORMAT)
879
880         result = {}
881         warning_msgs = {}
882         product_obj = product_obj.browse(cr, uid, product, context=context_partner)
883
884         uom2 = False
885         if uom:
886             uom2 = product_uom_obj.browse(cr, uid, uom)
887             if product_obj.uom_id.category_id.id != uom2.category_id.id:
888                 uom = False
889         if uos:
890             if product_obj.uos_id:
891                 uos2 = product_uom_obj.browse(cr, uid, uos)
892                 if product_obj.uos_id.category_id.id != uos2.category_id.id:
893                     uos = False
894             else:
895                 uos = False
896         fpos = fiscal_position and self.pool.get('account.fiscal.position').browse(cr, uid, fiscal_position) or False
897         if update_tax: #The quantity only have changed
898             result['tax_id'] = self.pool.get('account.fiscal.position').map_tax(cr, uid, fpos, product_obj.taxes_id)
899
900         if not flag:
901             result['name'] = self.pool.get('product.product').name_get(cr, uid, [product_obj.id], context=context_partner)[0][1]
902             if product_obj.description_sale:
903                 result['name'] += '\n'+product_obj.description_sale
904         domain = {}
905         if (not uom) and (not uos):
906             result['product_uom'] = product_obj.uom_id.id
907             if product_obj.uos_id:
908                 result['product_uos'] = product_obj.uos_id.id
909                 result['product_uos_qty'] = qty * product_obj.uos_coeff
910                 uos_category_id = product_obj.uos_id.category_id.id
911             else:
912                 result['product_uos'] = False
913                 result['product_uos_qty'] = qty
914                 uos_category_id = False
915             result['th_weight'] = qty * product_obj.weight
916             domain = {'product_uom':
917                         [('category_id', '=', product_obj.uom_id.category_id.id)],
918                         'product_uos':
919                         [('category_id', '=', uos_category_id)]}
920         elif uos and not uom: # only happens if uom is False
921             result['product_uom'] = product_obj.uom_id and product_obj.uom_id.id
922             result['product_uom_qty'] = qty_uos / product_obj.uos_coeff
923             result['th_weight'] = result['product_uom_qty'] * product_obj.weight
924         elif uom: # whether uos is set or not
925             default_uom = product_obj.uom_id and product_obj.uom_id.id
926             q = product_uom_obj._compute_qty(cr, uid, uom, qty, default_uom)
927             result['product_uom'] = default_uom
928             if product_obj.uos_id:
929                 result['product_uos'] = product_obj.uos_id.id
930                 result['product_uos_qty'] = qty * product_obj.uos_coeff
931             else:
932                 result['product_uos'] = False
933                 result['product_uos_qty'] = qty
934             result['th_weight'] = q * product_obj.weight        # Round the quantity up
935
936         if not uom2:
937             uom2 = product_obj.uom_id
938         # get unit price
939
940         if not pricelist:
941             warn_msg = _('You have to select a pricelist or a customer in the sales form !\n'
942                     'Please set one before choosing a product.')
943             warning_msgs += _("No Pricelist ! : ") + warn_msg +"\n\n"
944         else:
945             price = self.pool.get('product.pricelist').price_get(cr, uid, [pricelist],
946                     product, qty or 1.0, partner_id, {
947                         'uom': uom or result.get('product_uom'),
948                         'date': date_order,
949                         })[pricelist]
950             if price is False:
951                 warn_msg = _("Cannot find a pricelist line matching this product and quantity.\n"
952                         "You have to change either the product, the quantity or the pricelist.")
953
954                 warning_msgs += _("No valid pricelist line found ! :") + warn_msg +"\n\n"
955             else:
956                 result.update({'price_unit': price})
957         if warning_msgs:
958             warning = {
959                        'title': _('Configuration Error!'),
960                        'message' : warning_msgs
961                     }
962         return {'value': result, 'domain': domain, 'warning': warning}
963
964     def product_uom_change(self, cursor, user, ids, pricelist, product, qty=0,
965             uom=False, qty_uos=0, uos=False, name='', partner_id=False,
966             lang=False, update_tax=True, date_order=False, context=None):
967         context = context or {}
968         lang = lang or ('lang' in context and context['lang'])
969         if not uom:
970             return {'value': {'price_unit': 0.0, 'product_uom' : uom or False}}
971         return self.product_id_change(cursor, user, ids, pricelist, product,
972                 qty=qty, uom=uom, qty_uos=qty_uos, uos=uos, name=name,
973                 partner_id=partner_id, lang=lang, update_tax=update_tax,
974                 date_order=date_order, context=context)
975
976     def unlink(self, cr, uid, ids, context=None):
977         if context is None:
978             context = {}
979         """Allows to delete sales order lines in draft,cancel states"""
980         for rec in self.browse(cr, uid, ids, context=context):
981             if rec.state not in ['draft', 'cancel']:
982                 raise osv.except_osv(_('Invalid Action!'), _('Cannot delete a sales order line which is in state \'%s\'.') %(rec.state,))
983         return super(sale_order_line, self).unlink(cr, uid, ids, context=context)
984
985
986 class mail_compose_message(osv.osv):
987     _inherit = 'mail.compose.message'
988     def send_mail(self, cr, uid, ids, context=None):
989         context = context or {}
990         if context.get('default_model') == 'sale.order' and context.get('default_res_id') and context.get('mark_so_as_sent'):
991             wf_service = netsvc.LocalService("workflow")
992             wf_service.trg_validate(uid, 'sale.order', context['default_res_id'], 'quotation_sent', cr)
993         return super(mail_compose_message, self).send_mail(cr, uid, ids, context=context)
994
995 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: